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)
- Add the parent origin (exact
https://host). - Render template allows the methods you mount.
- Render token and API key from the same environment.
- 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 toPOST /payment_intentsas-is. onConfirmmustreturn confirmPayment(token). The SDK does not auto-confirm.- Do not call
validateFormor hostconfirmPayment({ iframe })— use the function injected intoonConfirm. - Wallet buttons do not take
appearance. Size the mount slot.heightdefault"48px". Native options go inbuttonProps; React host iframe chrome isiframeProps. - Passing
"5000"as the buttonamountcharges $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
succeededis not capture proof forautomatic_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
- Getting started — keys and create-intent
- Webhooks
- Testing
- amos-js README and react-amos-js README for appearance tokens,
buttonProps, and field focus