> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crossmint.com/llms.txt
> Use this file to discover all available pages before exploring further.

# One-Tap Pay - Tokens (React)

> Render only the Apple Pay or Google Pay button and build the rest of the checkout experience in your own UI

<Snippet file="enterprise-feature.mdx" />

## Introduction

This guide shows you how to configure Crossmint's Embedded Checkout so it renders **only the Apple Pay or Google Pay button**, and everything else on the screen is yours: your amount selector, your token UI, your success states. Rendering just the wallet button gives users a smooth one-tap purchase experience inside your own UI.

You'll learn how to:

* Restrict the embedded checkout to a single payment method
* Create the order server-side so the pay button is ready when the user sees it
* Hide the checkout's built-in inputs and surface payment errors in your layout
* Detect success and update your own UI

## The five required settings

The whole experience is this configuration. There is no single "Apple Pay only" switch: restricting the payment methods removes the tabs and forms, hiding the two inputs removes the rest, and the error rule gives payment failures a place to render.

```tsx theme={null}
<CrossmintEmbeddedCheckout
  orderId={order.orderId} // created server-side (step 2)
  clientSecret={order.clientSecret}
  payment={{
    receiptEmail: email, // 1. receipt email, so the user is never asked for it
    crypto: { enabled: false }, // 2. restrict methods to Apple Pay only...
    fiat: {
      enabled: true,
      allowedMethods: { applePay: true, card: false, googlePay: false },
    },
  }}
  appearance={{
    rules: {
      DestinationInput: { display: "hidden" }, // 3. hide the wallet input (the order carries the recipient)
      ReceiptEmailInput: { display: "hidden" }, // 4. hide the email input
      GlobalMessage: { display: "visible" }, // 5. surface payment errors
    },
  }}
/>
```

<Warning>
  `GlobalMessage: { display: "visible" }` is essential for pay-button-only integrations. It renders payment failures
  (for example, a payment blocked by risk checks) directly above the button. Without it, this layout has no other
  surface for errors, so a failed payment looks like nothing happened.
</Warning>

<Tip>
  To reach buyers on Android and desktop Chrome too, enable Google Pay — but keep exactly **one** wallet enabled per
  visitor, or the checkout shows a method selector in browsers that support both. Detect Apple Pay in your page and
  flip the flags:

  ```tsx theme={null}
  // detect after mount: window only exists in the browser, and frameworks
  // that render on the server (such as Next.js) crash on it during render
  const [applePay, setApplePay] = useState(false);

  useEffect(() => {
    const session = (window as { ApplePaySession?: { canMakePayments(): boolean } })
      .ApplePaySession;
    setApplePay(Boolean(session?.canMakePayments()));
  }, []);

  // in the component props:
  allowedMethods: applePay
    ? { applePay: true, card: false, googlePay: false }
    : { applePay: false, card: false, googlePay: true }
  ```

  This keeps the one-tap, single-button look everywhere: Apple Pay in Safari and on iPhone, Google Pay elsewhere.
</Tip>

## Prerequisites

<Note>
  Create everything below in the **same project**: the server key that creates the order, the client key the widget
  renders with, and the Apple Pay domain registration all work together, and the payment sheet validates against the
  project the order belongs to.
</Note>

<Steps>
  <Step title="Get API keys">
    From the <a href="https://staging.crossmint.com/console" target="_blank" rel="noopener">Crossmint Console</a>, under **Integrate → API Keys**, create:

    * A **client key** (`ck_staging_...`) with the `orders.read` scope, plus `users.create`, `users.read`,
      `wallets.read`, `wallets.create`, and `wallets:balance.read` if you also use Crossmint Auth and Wallets as
      the demo app does. Add every origin the app runs on (for example `http://localhost:3000` and your public
      domain) to the key's **origins allowlist**.
    * A **server key** (`sk_staging_...`) with the `orders.create` and `orders.read` scopes.
  </Step>

  <Step title="Get a public HTTPS domain">
    Apple Pay validates the merchant against the domain serving your page, so the domain must be publicly
    reachable over HTTPS — `localhost` cannot be registered. For local development, tunnel your dev server with
    <a href="https://ngrok.com" target="_blank" rel="noopener">ngrok</a> (`ngrok http 3000`) and use the resulting domain.
  </Step>

  <Step title="Register the domain for Apple Pay">
    In the console, under **Integrate → Apple Pay Domains**:

    1. Download the verification file and serve it at
       `https://<your-domain>/.well-known/apple-developer-merchantid-domain-association`. In Next.js, place it at
       `public/.well-known/apple-developer-merchantid-domain-association`.
    2. Enter your domain and click **Verify domain**. The status turns to `verified` immediately once the file is
       reachable.

    See the [Apple Pay setup guide](/payments/embedded/guides/apple-pay) for framework-specific hosting details.
  </Step>

  <Step title="Have a recipient wallet">
    The purchased tokens are delivered to a Solana wallet address. Use any address you control, or create wallets
    for your users with [Crossmint Wallets](/wallets/quickstarts/client-side-wallets) as the demo app does.
  </Step>
</Steps>

## Integration

The example buys XMEME, the staging test token, on Solana. Production tokens will not work in the staging environment.

<Steps>
  <Step title="Add environment variables">
    Create `.env.local` in your Next.js project root:

    ```sh theme={null}
    NEXT_PUBLIC_CROSSMINT_CLIENT_API_KEY="_YOUR_CLIENT_API_KEY_"
    SERVER_API_KEY="_YOUR_SERVER_API_KEY_"
    ```
  </Step>

  <Step title="Create the order server-side">
    Create `app/checkout/actions.ts`. The server key never reaches the browser, and the order carries the recipient and the receipt email, so the checkout never needs to ask for them:

    ```ts theme={null}
    "use server";

    const serverApiKey = process.env.SERVER_API_KEY ?? "";

    const baseUrl = serverApiKey.startsWith("sk_production")
      ? "https://www.crossmint.com"
      : "https://staging.crossmint.com";

    // XMEME test token on Solana (staging only)
    const TOKEN_LOCATOR = "solana:7EivYFyNfgGj8xbUymR7J4LuxUHLKRzpLaERHLvi7Dgu";

    export async function createOrder(
      amountUsd: string,
      email: string,
      walletAddress: string,
    ) {
      const res = await fetch(`${baseUrl}/api/2022-06-09/orders`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-api-key": serverApiKey,
        },
        body: JSON.stringify({
          lineItems: {
            tokenLocator: TOKEN_LOCATOR,
            executionParameters: {
              mode: "exact-in", // amount is the exact USD to spend
              amount: amountUsd,
              maxSlippageBps: "500",
            },
          },
          payment: {
            method: "card", // wallet payments ride the card rail at creation time
            receiptEmail: email,
          },
          recipient: {
            walletAddress,
          },
        }),
      });

      const data = await res.json();
      if (!res.ok) {
        throw new Error(data.message ?? "Failed to create order");
      }
      return {
        orderId: data.order.orderId as string,
        clientSecret: data.clientSecret as string,
      };
    }
    ```

    <Note>
      The base URL must match your key's environment: `sk_staging_` keys pair with `staging.crossmint.com` and
      `sk_production_` keys with `www.crossmint.com`.
    </Note>
  </Step>

  <Step title="Render only the Apple Pay button">
    Create `app/checkout/page.tsx`. Three details make the experience feel native:

    * **Create the order as soon as the user picks an amount**, not on a separate confirm button. Order creation takes a moment, so starting early means the Apple Pay button is ready by the time the user looks at it.
    * **Show a loader until the iframe reports content** via the `ui:height.changed` message, then reveal the button.
    * **The Apple Pay button is the CTA.** Do not add your own "Continue" button in front of it; one tap should open the payment sheet.

    ```tsx theme={null}
    "use client";

    import { useEffect, useState } from "react";
    import {
      CrossmintProvider,
      CrossmintCheckoutProvider,
      CrossmintEmbeddedCheckout,
      useCrossmintCheckout,
    } from "@crossmint/client-sdk-react-ui";

    import { createOrder } from "./actions";

    const RECIPIENT_WALLET = "_YOUR_SOLANA_WALLET_ADDRESS_";
    const RECEIPT_EMAIL = "_YOUR_EMAIL_";
    const AMOUNTS = ["1", "5", "10"];

    function Checkout() {
      const [amount, setAmount] = useState("1");
      const [order, setOrder] = useState<{
        orderId: string;
        clientSecret: string;
      } | null>(null);
      const [ready, setReady] = useState(false);
      const { order: checkoutOrder } = useCrossmintCheckout();

      // create the order as soon as the user picks an amount, so the Apple Pay
      // button is already loading by the time they look at it. The cleanup flag
      // discards responses from amounts the user has already moved past.
      useEffect(() => {
        let current = true;
        setOrder(null);
        setReady(false);
        void createOrder(amount, RECEIPT_EMAIL, RECIPIENT_WALLET).then((created) => {
          if (current) {
            setOrder(created);
          }
        });
        return () => {
          current = false;
        };
      }, [amount]);

      // reveal the button once the iframe has content (ui:height.changed)
      useEffect(() => {
        const onMessage = (e: MessageEvent) => {
          if (!e.origin.endsWith(".crossmint.com")) return;
          const event = (e.data as { event?: string } | null)?.event;
          if (event === "ui:height.changed" || event === "ui:express-checkout.ready") {
            setReady(true);
          }
        };
        window.addEventListener("message", onMessage);
        return () => window.removeEventListener("message", onMessage);
      }, []);

      // success = the order reaches the delivery phase
      const succeeded =
        checkoutOrder?.phase === "delivery" || checkoutOrder?.phase === "completed";

      if (succeeded) {
        return <p className="text-xl font-semibold">Payment complete. Tokens on the way!</p>;
      }

      return (
        <div className="w-full max-w-sm">
          <div className="mb-6 flex gap-2">
            {AMOUNTS.map((a) => (
              <button
                key={a}
                onClick={() => setAmount(a)}
                className={`flex-1 rounded-xl border px-4 py-2 font-medium ${
                  amount === a ? "border-black bg-black text-white" : "border-gray-300"
                }`}
              >
                ${a}
              </button>
            ))}
          </div>

          {!ready && <p className="py-4 text-center text-gray-500">Loading…</p>}
          <div className={ready ? "" : "invisible absolute h-0 overflow-hidden"}>
            {order && (
              <CrossmintEmbeddedCheckout
                key={order.orderId}
                orderId={order.orderId}
                clientSecret={order.clientSecret}
                payment={{
                  receiptEmail: RECEIPT_EMAIL,
                  crypto: { enabled: false },
                  fiat: {
                    enabled: true,
                    allowedMethods: { applePay: true, card: false, googlePay: false },
                  },
                }}
                appearance={{
                  rules: {
                    DestinationInput: { display: "hidden" },
                    ReceiptEmailInput: { display: "hidden" },
                    GlobalMessage: { display: "visible" },
                  },
                }}
              />
            )}
          </div>
        </div>
      );
    }

    export default function CheckoutPage() {
      return (
        <CrossmintProvider apiKey={process.env.NEXT_PUBLIC_CROSSMINT_CLIENT_API_KEY!}>
          <CrossmintCheckoutProvider>
            <main className="flex min-h-screen flex-col items-center justify-center bg-white p-6">
              <h1 className="mb-6 text-2xl font-bold">Buy XMEME</h1>
              <Checkout />
            </main>
          </CrossmintCheckoutProvider>
        </CrossmintProvider>
      );
    }
    ```

    Note the `key={order.orderId}`: changing the amount creates a fresh order, and keying the component by order id remounts the widget cleanly instead of navigating the iframe in place.
  </Step>

  <Step title="Run your app">
    <Snippet file="run-your-app.mdx" />
  </Step>

  <Step title="Test with Apple Pay">
    Open your **registered HTTPS domain** (not `localhost`) in Safari 17+ on macOS, or on an iPhone running iOS 17+, with a card added to Apple Wallet. Use a physical device rather than the iOS Simulator, which does not render Apple Pay. In staging the payment is processed in the PSP's sandbox, so the card in your Wallet is never actually charged.

    To exercise the full flow from any browser during development, temporarily flip the allowed methods to the card form and pay with the staging test card `4242 4242 4242 4242` (any future expiry, any CVC):

    ```tsx theme={null}
    allowedMethods: { applePay: false, card: true, googlePay: false }
    ```

    More on testing can be found [here](/payments/advanced/testing-tips#test-credit-card-numbers).
  </Step>
</Steps>

## One-Tap on Mobile

The five settings above are order and URL parameters, so the same configuration renders inside a native app unchanged. The recommended integration paths are the <a href="https://github.com/Crossmint/crossmint-checkout-swift" target="_blank" rel="noopener">Swift</a> and [Kotlin](/sdk-reference/checkout/kotlin/index) SDKs, which render and configure the checkout for you. If you prefer full control and do not want to add an SDK, create the order exactly as in step 2 and render the checkout in your own WebView following the [Mobile WebView Integration guide](/payments/embedded/guides/webview-integration):

```tsx theme={null}
// React Native: the same payment and appearance configuration, in a WebView
const checkoutUrl =
    `https://staging.crossmint.com/sdk/2024-03-05/embedded-checkout?` +
    new URLSearchParams({
        orderId: order.orderId,
        clientSecret: order.clientSecret,
        payment: JSON.stringify({
            receiptEmail: RECEIPT_EMAIL, // must also be here, or the email input renders
            crypto: { enabled: false },
            fiat: {
                enabled: true,
                allowedMethods: { applePay: true, card: false, googlePay: false },
            },
        }),
        appearance: JSON.stringify({
            rules: {
                DestinationInput: { display: "hidden" },
                ReceiptEmailInput: { display: "hidden" },
                GlobalMessage: { display: "visible" },
            },
        }),
        apiKey: CLIENT_API_KEY,
    }).toString();

<WebView source={{ uri: checkoutUrl }} /* configuration from the WebView guide */ />;
```

Two things get simpler on mobile:

* **Simpler wallet detection.** The operating system determines the wallet, so replace the `ApplePaySession` check from the tip above with a platform check: enable Apple Pay on iOS and Google Pay on Android (`Platform.OS === "ios"` in React Native), keeping one wallet enabled per device.
* **No Apple Pay domain registration.** The checkout page is served from `crossmint.com`, which is already enabled for Apple Pay, so the domain steps in the prerequisites apply to the web integration only.

The [Mobile WebView Integration guide](/payments/embedded/guides/webview-integration) covers the WebView configuration, order tracking from a native app, and mobile testing.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The payment sheet shows &#x22;Failed to validate merchant. Please try again.&#x22;">
    Apple could not validate the domain serving your page. Check, in order:

    1. **You are on a registered domain.** The page must be served from a domain that shows `verified` under
       **Apple Pay Domains** in the console — `localhost` always fails validation. Use your ngrok or production
       domain instead.
    2. **The domain matches exactly.** The registration is per host: `app.example.com` and `example.com` are
       different domains, and a new ngrok tunnel gets a new domain that needs registering again.
    3. **Everything belongs to one project.** The order (server key), the widget's `apiKey` (client key), and the
       Apple Pay domain registration must all come from the same console project. If the order was created with a
       key from a different project, validation fails even with a verified domain.
  </Accordion>

  <Accordion title="The Apple Pay button does not render, or Safari shows &#x22;Apple Pay is not available&#x22;">
    * Serve the page from your registered HTTPS domain: even in Safari, Apple Pay is unavailable on plain
      `http://localhost`, which surfaces as an "Apple Pay is not available" message. Open the app through your
      ngrok or production domain instead.
    * Apple Pay renders in Safari 17+ on macOS and iOS 17+; other browsers show nothing where the button would
      be (enable `googlePay: true` to cover them). Test in Safari or on an iPhone.
    * Use a physical device: the iOS Simulator does not render Apple Pay.
    * Confirm `payment.fiat.allowedMethods` sets `applePay: true` and the order was created successfully (the
      `ui:height.changed` message only fires once the iframe has content).
  </Accordion>

  <Accordion title="The checkout iframe does not load at all">
    * Add the exact origin the app runs on (scheme + host + port) to the client key's **origins allowlist** in the
      console.
    * Match the base URL to the key environment: `ck_staging_`/`sk_staging_` keys pair with
      `staging.crossmint.com`, production keys with `www.crossmint.com`.
  </Accordion>

  <Accordion title="The console shows &#x22;The API key provided doesn't have the required scopes&#x22;">
    The error names the missing scope. The widget's client key needs `orders.read`; if you also use Crossmint Auth
    and Wallets like the demo app, add `users.create`, `users.read`, `wallets.read`, `wallets.create`, and
    `wallets:balance.read`. Edit the key's scopes in the console under **Integrate → API Keys**.
  </Accordion>

  <Accordion title="A payment fails and nothing appears on screen">
    Pass `GlobalMessage: { display: "visible" }` in `appearance.rules`. In the pay-button-only layout this rule is
    the only surface where payment errors (such as a payment blocked by risk checks) can render.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Take it to mobile" icon="mobile" href="/payments/embedded/guides/webview-integration">
    Render the same experience inside your iOS, Android, or React Native app
  </Card>

  <Card title="Apple Pay domain setup" icon="apple" href="/payments/embedded/guides/apple-pay">
    Host the domain association file and verify your domain
  </Card>

  <Card title="Customize UI" icon="paintbrush" href="/payments/embedded/guides/ui-customization">
    Match the button and messages to your brand
  </Card>

  <Card title="Payment methods" icon="credit-card">
    <a href="/payments/embedded/guides/payment-methods">Enable Google Pay or cards</a> with the same pattern
  </Card>
</CardGroup>

<Snippet file="memecoins_faq.mdx" />
