Embedded checkout
Put Payments AI payment fields on your own web page with one <script> tag. The buyer never
leaves your site.
Pick the page that matches what you sell:
| You sell | Server code you write | Guide |
|---|---|---|
| One product plan you created in Payments AI | None | This page |
| One price your own code works out | One endpoint that charges | Custom amount checkout |
| Several items in one payment | One call that creates orders | Order checkout |
How it works
- You create a product and plan in Payments AI once, and copy the
planId. - Your web page loads the plan, shows its name and price, and mounts the payment fields.
- The buyer clicks Pay. The embed charges the buyer, runs any bank check (such as 3-D
Secure), and then calls your
onComplete.
There is no server code and no API key on the web page. The embed talks to Payments AI directly.
Before you start
You need a planId. Create a product with a plan from your server or from the dashboard. See
Quickstart, steps 1–3.
Start in sandbox: create the plan with a pai_test_* key and use environment: 'sandbox'
below. A sandbox plan does not exist in live, and a live plan does not exist in sandbox.
On your web page
The embed shows payment fields only: email, billing address, a promo code field, and payment method. It does not show the product name or price. Your page shows them, and the snippet below does that.
<div id="summary"></div>
<p id="total"></p>
<div id="checkout"></div>
<button id="pay" disabled>Pay</button>
<p id="message"></p>
<script src="https://js.managed.payments.ai/v1/checkout.js"></script>
<script>
const API = 'https://sandbox.mor.payments.ai/api';
const planId = 'YOUR_PLAN_ID';
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Request failed (${response.status})`);
return response.json();
}
function showMessage(text) {
document.getElementById('message').textContent = text;
}
async function start() {
// 1. Show what the buyer pays for
const plan = await getJson(`${API}/v1/checkout/plans/${planId}`);
document.getElementById('summary').textContent =
`${plan.productName}: ${plan.amount} ${plan.currency.toUpperCase()}`;
// 2. Mount the payment fields
const checkout = await PaymentsAICheckout.mount(document.getElementById('checkout'), {
planId,
environment: 'sandbox',
onTaxChange: (tax) => {
// 3. Show tax and the total once the buyer enters their address
if (!tax.calculated) return;
document.getElementById('total').textContent =
`Tax ${tax.taxAmount}, total ${tax.total} ${tax.currency.toUpperCase()}`;
},
onComplete: async ({ receiptId }) => {
// 5. Show the receipt
const receipt = await getJson(`${API}/v1/checkout/plans/${planId}/receipts/${receiptId}`);
showMessage(`Paid ${receipt.total} ${receipt.currency.toUpperCase()}, tax included`);
},
});
// 4. Pay
const pay = document.getElementById('pay');
pay.disabled = false;
pay.addEventListener('click', async () => {
try {
await checkout.submit();
} catch (error) {
showMessage(error.message);
}
});
}
start().catch((error) => showMessage(error.message));
</script>
What the code does:
- Show the product.
GET /v1/checkout/plans/{planId}is public (no API key). Useful fields:productName,productDescription,productImagePath,amount,currency,billingPeriod,lengthOfBillingPeriod.amountis in major units, so19.99means $19.99. - Mount the payment fields.
mount()resolves once the fields are on the page. - Show tax. Once the buyer has entered a full billing address,
onTaxChangegives you the tax and the total they will pay. Until then, showamountwithout tax. See Tax. - Pay.
submit()charges the buyer. Keep the button disabled untilmount()resolves. - Show the receipt.
onCompletegives you{ planId, receiptId }. The receipt hassubtotal,taxAmount,total, andcurrency: what was actually charged.
If the bank sends the buyer to another page for a check, the buyer comes back to returnUrl
(by default, this page) and onComplete is not called.
Tax
Until the buyer enters a billing address, show the plan price without tax. After the address is
complete, onTaxChange tells you the tax and what they will pay.
calculated: true— tax is known.taxAmountis the tax.totalis what the buyer pays today. Amounts are in major units, so19.99means $19.99. WhentaxBehaviorisexclusive, tax is added on top of the price. When it isinclusive,subtotalalready includes the tax: usetotal, and do not addtaxAmounton top ofsubtotal.calculated: false— tax is not known yet. Keep showing the price without tax. This happens while the address is incomplete, while a new address is being priced, while a promo code is applied, and when tax cannot be worked out for that address. This is not a tax of zero.
With payerType: 'business', a valid tax ID the buyer enters is included. If that ID exempts
them, calculated is still true and taxAmount is 0.
You can call the preview yourself:
POST /v1/checkout/plans/{planId}/tax with
{ "address": { "country": "US", "state": "NY", "postalCode": "10001" } }. The route is public
and does not charge the buyer. HTTP 422 with code checkout.tax_unavailable means tax cannot
be calculated for this plan and address. Keep showing the price without tax. A promo code is
not included in the result, so do not use it while a promo is applied.
Terms and consent
The embed shows a consent line under the payment fields. You do not pass a terms URL in
mount(). Links come from the same public plan response you use for the product summary
(termsUrl, privacyUrl, returnPolicyUrl, eulaUrl, plus Buyer Terms). Set seller policies
in the dashboard or with the checkout customization tools.
Styling and branding
When you load the plan with GET /v1/checkout/plans/{planId}, the customization object
describes how checkout looks:
- Inside the payment fields:
themeMode(lightordark) andbuttonColor(mapped to the field accent). Change these in the dashboard checkout builder or with checkout customization APIs. - On your page: show the product, price, and your own Pay button. Style that button with
buttonColorandbuttonTextColorfrom the samecustomizationobject. The embed also usesbackgroundColor,fontColor,fontFamily,inputStyle, andlogoUrlon its own chrome (promo row, consent line, logo). - Dark theme: if
themeModeisdarkand you did not setbackgroundColor, use a dark background behind the embed so field text stays readable.
Currency always comes from the plan. Field labels are English by default. Payments AI does not
add a locale option on mount().
Handle errors
Both mount() and submit() return promises. Catch both. When something fails during payment,
onError is called with { message, code } and submit() rejects with the same message.
code | When it happens |
|---|---|
checkout.mount_failed | mount() could not load the plan or merchant config |
checkout.plan_not_found | Wrong planId or plan is in the other environment |
checkout.plan_not_configured | Plan exists but is not linked for payments |
checkout.provider_unavailable | Payments provider error during confirm |
checkout.incomplete | Buyer clicked Pay before address or card was complete |
checkout.confirmation_token_failed | Could not create a payment token from the fields |
checkout.confirm_failed | Confirm request to Payments AI failed |
checkout.payment_failed | Card declined, 3-D Secure failed, or another payment error |
checkout.promo_code_invalid | Promo code is invalid or cannot be applied |
checkout.amount_below_renewal | Renewal price is below the minimum |
checkout.return_url_forbidden | returnUrl is not allowed for this checkout |
checkout.tax_unavailable | Tax cannot be calculated for this plan and address |
checkout.receipt_not_found | Receipt id does not match this checkout |
checkout.invoice_not_payable | Invoice cannot be paid |
checkout.invoice_already_linked | Invoice is already linked to an order |
order.not_found | Wrong orderId in order mode |
order.not_open | Order is no longer open for payment |
order.payment_in_flight | A payment is already in progress for the order |
Test in sandbox
Use a sandbox plan and environment: 'sandbox'. For card payments, use test card number
4242 4242 4242 4242, any future expiry, and any CVC. If the bank sends the buyer to another
page for a check (such as 3-D Secure), they return to returnUrl and onComplete is not called
until the payment finishes.
After payment on your server
onComplete runs in the buyer's browser when payment succeeds. For a durable record (refunds,
reporting, fulfillment), subscribe to Webhooks and handle payment events on your
server.
React and Vue
Mount once when the checkout container is on the page, and call destroy() when you remove it.
import { useEffect, useRef } from 'react';
export function Checkout({ planId }) {
const containerRef = useRef(null);
useEffect(() => {
const host = containerRef.current;
if (!host) return;
const mountHost = document.createElement('div');
host.appendChild(mountHost);
let controls;
let disposed = false;
window.PaymentsAICheckout.mount(mountHost, {
planId,
environment: 'sandbox',
})
.then((mounted) => {
controls = mounted;
if (disposed) {
controls.destroy();
}
})
.catch((error) => {
console.error(error);
});
return () => {
disposed = true;
controls?.destroy();
mountHost.remove();
};
}, [planId]);
return <div ref={containerRef} />;
}
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
const container = ref(null);
let controls;
let mountHost;
let disposed = false;
onMounted(async () => {
const host = container.value;
if (!host) return;
mountHost = document.createElement('div');
host.appendChild(mountHost);
try {
controls = await window.PaymentsAICheckout.mount(mountHost, {
planId: 'YOUR_PLAN_ID',
environment: 'sandbox',
});
} catch (error) {
console.error(error);
return;
}
if (disposed) {
controls.destroy();
}
});
onUnmounted(() => {
disposed = true;
controls?.destroy();
mountHost?.remove();
});
</script>
<template>
<div ref="container" />
</template>
Accessibility, card data, and security
Payment fields live inside the payment provider's secure frames. Label your Pay button clearly and do not rely on color alone for errors.
Card numbers are typed into those frames, not into your page HTML. Do not put a Payments AI API key in the page.
The checkout script may load on any site you control. The buyer's browser calls Payments AI directly over HTTPS. No API key is required on the page.
Go live
- Create the product and plan with a
pai_live_*key. - In the snippet, set
APItohttps://mor.payments.ai/apiandenvironmentto'live'.
Everything else stays the same. See Sandbox vs live.
Reference
mount(element, options)
| Option | Required | Description |
|---|---|---|
planId | Yes | The plan the buyer pays for. |
environment | Yes | 'sandbox' or 'live'. Must match the key you used to create the plan. |
payerType | No | 'individual' (default) or 'business'. 'business' adds a tax ID field. |
email | No | Prefills the email field when buyer fields are shown. On the token, setEmail() overrides; with visible fields the buyer's email element value is used unless you call setEmail(). With hideBuyerFields, mount email is sent on the token. |
billingDetails | No | { name, address } prefills billing name and address (postalCode, line1, and other supported keys). |
hideBuyerFields | No | When true, hides email and address fields. Requires email and a complete billingDetails address. |
promoCode | No | Fills in and applies a promo code, for example from a marketing link. |
returnUrl | No | Where the buyer comes back to after a bank check on another page. Defaults to the current page. |
onTaxChange | No | Called with the tax preview whenever the billing address or tax ID changes. See Tax. |
onComplete | No | Called with { planId, receiptId } after a successful payment. |
onError | No | Called with { message, code } when a payment fails. See Handle errors. submit() rejects with the same message. |
onReady | No | Called once the payment fields are mounted. |
Controls returned by mount()
| Method | Description |
|---|---|
submit() | Charges the buyer. Returns a promise. |
setEmail(email) | Stores the buyer email for payment. It is always sent on createConfirmationToken when set. Intended when hideBuyerFields is true or the email arrives after mount; the embed does not update a visible email field, so buyers may still see the prefilled address email while a different email is charged. |
destroy() | Removes the payment fields from the page. |
The script URL https://js.managed.payments.ai/v1/checkout.js is stable. Breaking changes ship
as v2.
Prefill buyer details
When you already know who is paying, pass email and billingDetails so the buyer sees fewer
empty fields. To skip email and address entirely (for example on a logged-in funnel), set
hideBuyerFields: true and supply a non-empty email plus a complete billing address
(country, line1, city, postalCode, and name). Payment fields still mount; tax preview
uses the supplied address in plan and order mode.
await PaymentsAICheckout.mount(document.getElementById('checkout'), {
planId: 'YOUR_PLAN_ID',
environment: 'sandbox',
email: 'buyer@example.com',
billingDetails: {
name: 'Jane Buyer',
address: {
country: 'US',
line1: '123 Main St',
city: 'Austin',
postalCode: '78701',
},
},
hideBuyerFields: true,
});
Call setEmail() before submit() when the email arrives after mount (for example from your
auth layer). The stored email is what goes on the payment token.
Troubleshooting
| Symptom | What to do |
|---|---|
mount() rejects with status 404 | Check the planId, and check that environment matches the key that created the plan. |
mount() rejects: "has no linked plan" | The plan is not ready for checkout. Create it again, or get help. |
| No product name or price on the page | Expected. The embed shows payment fields only. Render the plan yourself (step 1). |
submit() rejects: "incomplete" | The buyer has not finished the address or card fields. |
Next steps
- Custom amount checkout: charge a price your own code works out
- Order checkout: several items in one payment
- Webhooks: server-side payment notifications
- API Reference: every checkout endpoint
- Error catalog: error codes and fixes