Embedded checkout

Collect cards, bank accounts, Google Pay, and Apple Pay in your UI using Amos-hosted iframes. Sensitive data never enters the merchant DOM.

Source of truth for APIs: the installed package README and types (@amos.com/amos-js, @amos.com/react-amos-js, @amos.com/node). This page is the partner guide.

Install

React:

npm install @amos.com/react-amos-js @amos.com/node

Vanilla:

npm install @amos.com/amos-js @amos.com/node

@amos.com/node is a peer of both clients (install it even in browser-only TypeScript for OpenAPI types). Requires Node 22+ on the server.

Prefer React wrappers in a React app; otherwise amos-js.

Architecture

Mount iframe with render token
  → customer fills fields (can take a long time — OK)
  → validateForm  (card / bank only)
  → POST /payment_intents or /setup_intents   ← mint embed token HERE
  → await confirmPayment / confirmSetup       ← use it immediately
  → { status: "succeeded" | "failed" }
  → fulfill from webhook or GET /payment_intents/{id}

Create the intent on submit (or wallet tap), then confirm immediately. Prefetching an intent when the form opens is how you get Signature has expired.

Dashboard checklist (blank iframe)

  1. Add the parent origin (exact https://host).
  2. Render template allows the methods you mount.
  3. Render token and API key from the same environment.
  4. Bank: leave verification on unless this is a virtual terminal (options.verification: false).

Mismatch → blank iframe or method not allowed.

Two tokens

Render token Embed token
Created Dashboard render template POST /payment_intents or /setup_intents
Lives Client Server, then browser for one confirm
Failure mode Blank iframe {"errors":{"base":["Signature has expired"]}}

Non-express: card and bank

Same components for payment (charge now) and setup (save a payment method).

Payment Setup
Server POST /payment_intents POST /setup_intents
Client await confirmPayment({ …, token }) await confirmSetup({ …, token })

Confirm returns:

type ConfirmPaymentResult =
  | { status: "succeeded"; paymentIntent: PaymentIntent }
  | { status: "failed"; paymentIntent?: PaymentIntent };

Await it. Both succeeded and failed unlock the UI. Field errors stay in the iframe — do not duplicate them on the host. { status: "succeeded" } is authorization UX, not settlement proof.

PaymentIntent / SetupIntent types come from components["schemas"]["…"] in @amos.com/node, not from the client SDK.

React (card)

Wrap the mount in a host <form>. Enter in the iframe submits that form (PCI-safe).

import { useRef, useState } from "react";
import {
  AmosCreditCardPaymentMethodForm,
  confirmPayment,
  validateForm,
} from "@amos.com/react-amos-js";

function CheckoutForm({ renderToken }: { renderToken: string }) {
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const [isValid, setIsValid] = useState(false);
  const [processing, setProcessing] = useState(false);

  async function onSubmit(event: React.FormEvent) {
    event.preventDefault();
    setProcessing(true);
    try {
      if (!(await validateForm({ iframeRef }))) return;

      const response = await fetch("/api/payment-intents", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ amount: 5000 }), // cents; compute on the server, never trust the client
      });
      const { token } = (await response.json()) as { token: string };
      const result = await confirmPayment({ iframeRef, token });
      if (result.status === "succeeded") {
        // Success UX. Fulfill from webhook / retrieve.
      }
    } finally {
      setProcessing(false);
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <AmosCreditCardPaymentMethodForm
        ref={iframeRef}
        renderToken={renderToken}
        additionalFields={{ cardholderName: true }}
        onValidityChange={({ isValid }) => setIsValid(isValid)}
      />
      <button type="submit" disabled={!isValid || processing}>
        {processing ? "Processing…" : "Pay now"}
      </button>
    </form>
  );
}

The component mounts into a wrapper div; ref still points at the iframe. Pass that same iframeRef to validateForm / confirmPayment / confirmSetup / resetForm.

Optional: appearance (card/bank only), defaultValues (name + billing address only — never PAN/CVC/account numbers), billingAddressRequirement: "country" | "full", card onCardBrandChanged, resetForm after success, focusField.

Do not create the intent in useEffect on mount.

Vanilla (card)

import {
  mountAmosCreditCardPaymentMethodForm,
  validateForm,
  confirmPayment,
} from "@amos.com/amos-js";

const card = mountAmosCreditCardPaymentMethodForm("#card-form", {
  renderToken,
  additionalFields: { cardholderName: true },
  onValidityChange: ({ isValid }) => {
    document.querySelector("#pay-now")!.disabled = !isValid;
  },
});

document.querySelector("#checkout")!.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (!(await validateForm({ iframe: card.iframe }))) return;
  const { token } = await fetch("/api/payment-intents", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ amount: 5000 }),
  }).then((r) => r.json());
  const result = await confirmPayment({ iframe: card.iframe, token });
  if (result.status === "succeeded") {
    console.log(result.paymentIntent.id);
  }
});

Bank: AmosBankAccountPaymentMethodForm / mountAmosBankAccountPaymentMethodForm.

Method tabs

Mount every method you offer and hide inactive panels with CSS (hidden). Conditional unmount reloads the iframe and flashes the skeleton.

<div hidden={method !== "card"}><AmosCreditCardPaymentMethodForm … /></div>
<div hidden={method !== "bank"}><AmosBankAccountPaymentMethodForm … /></div>

Loading

Card/bank mounts show a field-shaped skeleton; wallets show a button-shaped skeleton at height (default "48px"). Do not overlay your own placeholder or hide the mount until “ready.”

Bank ACH / Plaid

When verification is required, the SDK hides routing/account fields and renders a Connect bank account button on the parent page, then opens Plaid Link. Confirm still goes through validateForm / confirmPayment / confirmSetup. The SDK attaches payment_method.plaid (public_token, account_id).

When Behavior
Render token verification: false Manual bank form
intent: "setup" (verification on) Always Connect
intent: "payment" + requireAchVerification: true Connect
intent: "payment" and verification omitted/false Manual bank form

requireAchVerification is your business rule (the Pay API no longer exposes Account.ach_threshold). Changing intent remounts the bank iframe. resetForm disconnects Plaid.

CSP on the parent page: allow frame-src for https://embed-sandbox.amos.com or https://embed.amos.com (and https://cdn.plaid.com https://*.plaid.com when using Connect). Also script-src https://cdn.plaid.com for Plaid. Never load PLAID_CLIENT_ID / PLAID_SECRET in the browser.

Express: Google Pay and Apple Pay

mount button → customer taps → onConfirm → your server creates a PI → return confirmPayment(token)
  • Required: amount (string major-currency decimal, e.g. "50.00" for $50.00), merchantName, onConfirm.
  • The iframe converts that string to cents in paymentIntentCreateAttributes.amount. Forward those attributes to POST /payment_intents as-is.
  • onConfirm must return confirmPayment(token). The SDK does not auto-confirm.
  • Do not call validateForm or host confirmPayment({ iframe }) — use the function injected into onConfirm.
  • Wallet buttons do not take appearance. Size the mount slot. height default "48px". Native options go in buttonProps; React host iframe chrome is iframeProps.
  • Passing "5000" as the button amount charges $5,000.00.
<AmosGooglePayButton
  renderToken={renderToken}
  amount="50.00"
  merchantName="Example Store"
  onConfirm={async ({
    paymentIntentCreateAttributes,
    customerCreateAttributes,
    confirmPayment,
  }) => {
    const response = await fetch("/api/payment-intents", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        customer: customerCreateAttributes,
        paymentIntent: paymentIntentCreateAttributes,
      }),
    });
    const { token } = (await response.json()) as { token: string };
    return confirmPayment(token);
  }}
/>

Apple Pay: Safari uses the native sheet; other browsers use Apple’s QR popup. The SDK shows a host waiting overlay with Cancel — do not reinvent iframe expand hacks.

Wallet confirm sends card_profile_attributes.wallet_payload only. Do not invent wallet_provider / PAN / cryptogram fields.

Server route

Your backend should expose something like:

POST /api/payment-intents  →  { "token": "<EmbedToken.token>" }
POST /api/setup-intents    →  { "token": "<EmbedToken.token>" }

Call it from submit / wallet onConfirm, not page load. Map Pay API errors to safe client messages. Never return the API key.

The Pay API body is nested snake_case (payment_intent, customer). If your browser sends camelCase from wallet onConfirm attributes, wrap them on the server:

import {
  createPayApiClient,
  AMOS_API_BASE_URL_SANDBOX,
  AMOS_API_VERSION,
} from "@amos.com/node";

const pay = createPayApiClient({
  baseUrl: AMOS_API_BASE_URL_SANDBOX,
  headers: {
    "X-Api-Key": process.env.AMOS_API_KEY!,
    "X-Api-Version": AMOS_API_VERSION,
  },
});

export async function POST(req: Request) {
  const body = await req.json();
  // Wallet onConfirm attributes are CreatePaymentIntentInput / CreateCustomerInput.
  // Pay API wants { payment_intent } / { customer }.
  if (body.customer) {
    const customer = await pay.POST("/customers", {
      body: { customer: body.customer },
    });
    if (customer.error) {
      return Response.json({ error: "customer" }, { status: 502 });
    }
    body.paymentIntent = {
      ...body.paymentIntent,
      customer_id: customer.data.id,
    };
  }
  const { data, error } = await pay.POST("/payment_intents", {
    body: {
      payment_intent: body.paymentIntent ?? {
        amount: 5000,
        capture_method: "automatic",
      },
    },
  });
  if (error || !data?.token) {
    return Response.json({ error: "intent" }, { status: 502 });
  }
  return Response.json({ token: data.token });
}

amount on this call is integer cents. Optional customer_id, metadata, statement_descriptor, recurring_payment — see CreatePaymentIntentInput in the reference.

Amounts

Surface Type $50.00
Pay API payment_intent.amount number, cents 5000
Wallet button amount prop string, major units "50.00"
Wallet paymentIntentCreateAttributes.amount number, cents 5000

PCI

  • Never collect PAN, CVV, or full account numbers in merchant DOM.
  • Never put API keys or raw confirm payloads on the client.
  • Bank verification uses Plaid Link on the parent page (SDK-owned Connect button). Still do not collect account numbers or Plaid secrets yourself.
  • Confirm succeeded is not capture proof for automatic_async. Use webhooks.

Removed APIs (do not copy old samples)

onResult / ConfirmationResult / incomplete, confirmPaymentIntent / confirmSetupIntent, onInitiatePaymentIntentRequest, fullWidth, top-level wallet buttonType / buttonStyle, @amos.com/pay-js-sdk, PAY_API_*, pay.amos.com hosts, last_payment_error on PaymentIntent.

Pitfalls

Symptom Fix
Blank iframe Origin/method not on the render template; env mismatch
Signature has expired Create intent on submit; confirm immediately
Confirm no-ops / always failed Pass the iframe ref, not the wrapper div; await the Promise
Spinner never stops Await confirm; unlock on both statuses
Tab switch flashes skeleton Keep all methods mounted; hide with CSS
GPay/Apple Pay overcharges Button amount is "50.00", not "5000"
Setup bank shows routing/account intent: "setup"
Connect never appears requireAchVerification: true; render-token verification not false
CSP blocks Plaid Allow cdn.plaid.com + *.plaid.com on the parent

Next