Skip to main content

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 sellServer code you writeGuide
One product plan you created in Payments AINoneThis page
One price your own code works outOne endpoint that chargesCustom amount checkout
Several items in one paymentOne call that creates ordersOrder checkout

How it works​

  1. You create a product and plan in Payments AI once, and copy the planId.
  2. Your web page loads the plan, shows its name and price, and mounts the payment fields.
  3. 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.

checkout.html
<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:

  1. Show the product. GET /v1/checkout/plans/{planId} is public (no API key). Useful fields: productName, productDescription, productImagePath, amount, currency, billingPeriod, lengthOfBillingPeriod. amount is in major units, so 19.99 means $19.99.
  2. Mount the payment fields. mount() resolves once the fields are on the page.
  3. Show tax. Once the buyer has entered a full billing address, onTaxChange gives you the tax and the total they will pay. Until then, show amount without tax. See Tax.
  4. Pay. submit() charges the buyer. Keep the button disabled until mount() resolves.
  5. Show the receipt. onComplete gives you { planId, receiptId }. The receipt has subtotal, taxAmount, total, and currency: 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. taxAmount is the tax. total is what the buyer pays today. Amounts are in major units, so 19.99 means $19.99. When taxBehavior is exclusive, tax is added on top of the price. When it is inclusive, subtotal already includes the tax: use total, and do not add taxAmount on top of subtotal.
  • 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.

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 (light or dark) and buttonColor (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 buttonColor and buttonTextColor from the same customization object. The embed also uses backgroundColor, fontColor, fontFamily, inputStyle, and logoUrl on its own chrome (promo row, consent line, logo).
  • Dark theme: if themeMode is dark and you did not set backgroundColor, 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.

codeWhen it happens
checkout.mount_failedmount() could not load the plan or merchant config
checkout.plan_not_foundWrong planId or plan is in the other environment
checkout.plan_not_configuredPlan exists but is not linked for payments
checkout.provider_unavailablePayments provider error during confirm
checkout.incompleteBuyer clicked Pay before address or card was complete
checkout.confirmation_token_failedCould not create a payment token from the fields
checkout.confirm_failedConfirm request to Payments AI failed
checkout.payment_failedCard declined, 3-D Secure failed, or another payment error
checkout.promo_code_invalidPromo code is invalid or cannot be applied
checkout.amount_below_renewalRenewal price is below the minimum
checkout.return_url_forbiddenreturnUrl is not allowed for this checkout
checkout.tax_unavailableTax cannot be calculated for this plan and address
checkout.receipt_not_foundReceipt id does not match this checkout
checkout.invoice_not_payableInvoice cannot be paid
checkout.invoice_already_linkedInvoice is already linked to an order
order.not_foundWrong orderId in order mode
order.not_openOrder is no longer open for payment
order.payment_in_flightA 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​

  1. Create the product and plan with a pai_live_* key.
  2. In the snippet, set API to https://mor.payments.ai/api and environment to 'live'.

Everything else stays the same. See Sandbox vs live.

Reference​

mount(element, options)​

OptionRequiredDescription
planIdYesThe plan the buyer pays for.
environmentYes'sandbox' or 'live'. Must match the key you used to create the plan.
payerTypeNo'individual' (default) or 'business'. 'business' adds a tax ID field.
emailNoPrefills 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.
billingDetailsNo{ name, address } prefills billing name and address (postalCode, line1, and other supported keys).
hideBuyerFieldsNoWhen true, hides email and address fields. Requires email and a complete billingDetails address.
promoCodeNoFills in and applies a promo code, for example from a marketing link.
returnUrlNoWhere the buyer comes back to after a bank check on another page. Defaults to the current page.
onTaxChangeNoCalled with the tax preview whenever the billing address or tax ID changes. See Tax.
onCompleteNoCalled with { planId, receiptId } after a successful payment.
onErrorNoCalled with { message, code } when a payment fails. See Handle errors. submit() rejects with the same message.
onReadyNoCalled once the payment fields are mounted.

Controls returned by mount()​

MethodDescription
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​

SymptomWhat to do
mount() rejects with status 404Check 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 pageExpected. 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​