Order checkout
An order is a list of items the buyer pays for in one payment. For example, a $10 t-shirt plus a $15 monthly course, charged together.
Selling one plan? Use Embedded checkout. One price your code works out? Use Custom amount checkout.
How it works
- Your server creates the order with your API key and gets back an
orderIdand acheckoutUrl. - The buyer pays, in one of two ways:
- Redirect: send the buyer to
checkoutUrl. Payments AI shows the items, takes payment, and shows the receipt. No web page code. - Embed: mount the payment fields on your own page with the
orderId. Your page shows the items.
- Redirect: send the buyer to
An order cannot change after you create it. For different items, create a new order.
On your server
1. Create an order
Call POST /v1/public-api/merchants/{merchantId}/orders with your API key. Always call it from
your server, never from the browser.
app.post('/api/checkout/order', async (req, res) => {
const cart = await loadCart(req.session.cartId);
const response = await fetch(
`https://sandbox.mor.payments.ai/api/v1/public-api/merchants/${MERCHANT_ID}/orders`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAI_API_KEY}`, // pai_test_* in sandbox
'Content-Type': 'application/json',
'Idempotency-Key': cart.id, // same key returns the same order
},
body: JSON.stringify({
currency: 'usd',
items: [
{ planId: 'YOUR_PLAN_ID', quantity: 1 },
{ label: 'T-shirt', unitPrice: 10.0, quantity: 1, type: 'one_time' },
],
}),
},
);
const order = await response.json();
// Redirect: send the buyer to order.checkoutUrl
// Embed: pass order.orderId to your web page
res.status(response.status).json(order);
});
Each item is one of three kinds. Its fields decide which:
| Item | Fields | Price comes from |
|---|---|---|
| Plan | planId, quantity | The plan |
| Custom | label, unitPrice, quantity, type (one_time or recurring) | You |
| Invoice | invoiceId | The invoice |
- A recurring custom item also needs
billingPeriod(day,week,month, oryear) andperiodLength.billingPeriod: "day"withperiodLength: 30means every 30 days. - On a recurring custom line you can set
trialPeriodDays(1–730). Recurring lines charge nothing today; the first recurring charge happens after the trial. Omit it when there is no trial. Every recurring line in the order — catalogue plan or custom — must use the same trial length. - Do not mix
planIdwithlabel/unitPriceon the same item; that combination is rejected. - If a
planIddoes not exist, or belongs to another merchant, create is rejected. We do not uselabelorunitPriceinstead. - Prices are in major units:
10.0means $10.00.
Every order is checked against these limits before it is created. A request that breaks one gets
400 with the field and a Recovery: step, and no order is created:
| Limit | Value |
|---|---|
currency | One of usd, eur, gbp, pln, chf, sek, dkk, nok, cad, aud |
unitPrice | At most 100000 |
One line (unitPrice × quantity) | At most 100000 |
| Order total, today and on renewal | At most 100000 (order.amount_above_ceiling) |
quantity | At most 10000 per line |
periodLength | At most one year: 365 day, 52 week, 12 month, 1 year |
trialPeriodDays (recurring only) | At most 730; must match across all recurring lines in the order |
The currency limit applies to every order, including one that only pays invoices: an invoice in
another currency cannot be paid through an order. The hosted invoice page applies the currency and
100000 total limits when it opens the order, so an invoice outside them gets 422
checkout.invoice_not_payable instead of a checkout that cannot be paid. That response includes
details.status (open or past_due) and no other invoice fields. The same 422 is returned
when the invoice is draft, paid, void, or uncollectible, again with only details.status.
The same limits are checked again when the buyer pays. An open order outside them, such as one
created before a limit applied, cannot be paid: confirming it returns 422
order.outside_limits, and nothing is charged. Create a new order within the limits instead.
The Idempotency-Key header is optional and unique per merchant. Sending the same key again
returns the existing order with its current status, transactionId, and subscription
linkage (not a frozen snapshot from the first create). Without it, every call creates a new order.
The response is 201 Created:
{
"orderId": "0199f0b2-3c4d-7e5f-9012-23456789abcd",
"checkoutUrl": "https://checkout.payments.ai/payment/order/{orderId}",
"currency": "usd",
"amountDue": 25.0,
"recurringAmount": 15.0,
"billingPeriod": "day",
"periodLength": 30,
"trialPeriodDays": null,
"items": [
{
"label": "T-shirt",
"unitPrice": 10.0,
"quantity": 1,
"lineTotal": 10.0,
"itemType": "one_time"
},
{
"label": "Course",
"unitPrice": 15.0,
"quantity": 1,
"lineTotal": 15.0,
"itemType": "recurring",
"billingPeriod": "day",
"periodLength": 30
}
]
}
| Field | Meaning |
|---|---|
status | open, completed, or expired. open covers unpaid orders and the window after checkout confirm before the payment row is linked. |
transactionId | Internal Payments AI transaction id (GET .../transactions/{transactionId}). null while unpaid, between checkout confirm and webhook linkage, or when the only matching payment row is pending or failed. After a successful payment (including later refund or dispute on that row), this is the internal id. |
subscriptionId | Internal subscription id when the order has one recurring line item and one subscription is linked. |
subscriptionIds | Internal subscription ids when the order has two or more recurring line items, or when multiple subscriptions are linked. |
amountDueis what the buyer pays today, before tax. See Tax.recurringAmountis what renews later, everyperiodLength×billingPeriod. It isnullwhen nothing renews.- After the buyer pays, use
transactionIdfromGET .../orders/{orderId}to load payment details fromGET .../transactions/{transactionId}. Between checkout confirm and webhook linkage,transactionIdstaysnulleven though checkout has started.
Redirect to the hosted page
Send the buyer to checkoutUrl. That is all. Payments AI shows every item, takes payment, and
shows the receipt. Sandbox URLs end in ?isSandbox=true.
Embed on your web page
The embed shows payment fields only. Your page shows the items, using the public
GET /v1/checkout/orders/{orderId} (no API key).
<pre id="summary"></pre>
<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 orderId = 'ORDER_ID_FROM_YOUR_SERVER';
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 the items
const order = await getJson(`${API}/v1/checkout/orders/${orderId}`);
const currency = order.currency.toUpperCase();
const lines = order.items.map(
(item) => `${item.label} × ${item.quantity}: ${item.lineTotal} ${currency}`,
);
lines.push(`Due today, before tax: ${order.amountDue} ${currency}`);
document.getElementById('summary').textContent = lines.join('\n');
// 2. Mount the payment fields
const checkout = await PaymentsAICheckout.mount(document.getElementById('checkout'), {
orderId,
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 (result) => {
// 5. Show the receipt
const receipt = await getJson(
`${API}/v1/checkout/orders/${result.orderId}/receipts/${result.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>
onTaxChange gives you the tax and the total due today once the buyer has entered a full
billing address. onComplete gives you { orderId, receiptId }. The receipt has subtotal,
taxAmount, total, and currency.
Options, controls, and errors work the same as in
Embedded checkout, with orderId in place of planId. You can
use the same email, billingDetails, and hideBuyerFields mount options to prefill or hide
buyer fields. Order checkout has no promoCode. When nothing is due today (for example a free
trial on recurring lines), hosted checkout and the embed mount Whop Elements in plan mode using the order's linked
externalPlanId so the buyer can save a card without a charge today; the buyer is charged after the trial ends.
Examples
T-shirt plus course
$25.00 today, then $15.00 every 30 days. The renewal is for the course only.
{
"currency": "usd",
"items": [
{ "label": "T-shirt", "unitPrice": 10.0, "quantity": 1, "type": "one_time" },
{
"label": "Course",
"unitPrice": 15.0,
"quantity": 1,
"type": "recurring",
"billingPeriod": "day",
"periodLength": 30
}
]
}
Your own prices only
Charge $59 today (setup plus the first week), then $40 every 7 days. No plans needed.
{
"currency": "usd",
"items": [
{ "label": "Setup", "unitPrice": 19.0, "quantity": 1, "type": "one_time" },
{
"label": "Membership",
"unitPrice": 40.0,
"quantity": 1,
"type": "recurring",
"billingPeriod": "day",
"periodLength": 7
}
]
}
Two plans you already created
{
"currency": "usd",
"items": [
{ "planId": "0199f0a1-2b3c-7d4e-8f90-123456789abc", "quantity": 1 },
{ "planId": "0199f0c3-4d5e-7f60-1234-3456789abcde", "quantity": 1 }
]
}
Both plans must belong to {merchantId}. If both are recurring, they must share the same
billing schedule.
Pay open invoices
{
"currency": "usd",
"items": [{ "invoiceId": "0199f0a1-2b3c-7d4e-8f90-123456789abc" }]
}
The invoice must be open or past due, belong to the same merchant, and not already be on another order. You can put several invoices in one order if they share a currency. Paying the order does not by itself mark the invoice paid.
Invoice list and detail also return a payUrl for open or past-due invoices with an amount
above zero. It opens /invoice/{invoiceId} on the hosted page. Otherwise payUrl is null.
Tax
amountDue on the order is the price before tax. Tax is one amount for the whole order, not a
separate rate on each item. The buyer pays more than amountDue once tax is known.
Show that tax before they pay:
- Hosted page: the tax and the total update as soon as the billing address is complete.
- Embed:
onTaxChangeis called withcalculated: trueonce tax is known.taxAmountis the tax andtotalis what the buyer pays today.calculated: falsemeans tax is not known yet: keep showingamountDuewithout tax. That is not a tax of zero. WhentaxBehaviorisinclusive,subtotalalready includes the tax, so usetotaland do not addtaxAmounton top. - Your own UI:
POST /v1/checkout/orders/{orderId}/taxwith the billing address, for example{ "address": { "country": "US", "state": "NY", "postalCode": "10001" } }. The route is public and does not charge the buyer. UsetaxAmountandtotalonly whencalculatedis true. HTTP 422 with codecheckout.tax_unavailablemeans tax cannot be calculated. Keep showing the price without tax.
A business buyer's valid tax ID (payerType: 'business') is included. If it exempts them,
calculated is true and taxAmount is 0. The order itself has no tax field. The receipt
shows the tax that was charged.
Limits
-
Up to 20 items per order.
-
One billing schedule per order, and one trial length for all recurring items. Different schedules are rejected at create.
-
One membership per order. Quantity on a recurring item multiplies its renewal (3 × $15/month = $45/month, one membership).
-
One tax amount for the whole order.
-
No promo codes.
-
You cannot change an order. Create a new order instead.
-
Custom items (you set the price) need an API key, so create orders from your server only.
-
A
planIdorinvoiceIdfrom another merchant is rejected. -
Each paid amount must meet the currency minimum:
Currency Minimum USD, EUR, GBP, CHF, CAD, AUD 1 PLN 2 DKK 2.5 SEK, NOK 3
Embed: consent and errors
Order mode shows the same consent links and onError code values as
Embedded checkout. Policy URLs come from
GET /v1/checkout/orders/{orderId}.
Reference
| Route | Auth | Use |
|---|---|---|
POST /v1/public-api/merchants/{merchantId}/orders | API key | Create an order (your server) |
GET /v1/public-api/merchants/{merchantId}/orders/{orderId} | API key | Read an order (your server) |
GET /v1/checkout/orders/{orderId} | None | Show the items (web page) |
GET /v1/checkout/orders/{orderId}/receipts/{receiptId} | None | Show the receipt (web page) |
The embed calls POST /v1/checkout/orders/{orderId}/confirm for you during submit(). Full
schemas are in the API Reference.
Next steps
- Embedded checkout: sell one plan
- Custom amount checkout: one price your code works out
- Merchant Public API: API keys and server routes