Skip to main content

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​

  1. Your server creates the order with your API key and gets back an orderId and a checkoutUrl.
  2. 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.

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.

server.js
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:

ItemFieldsPrice comes from
PlanplanId, quantityThe plan
Customlabel, unitPrice, quantity, type (one_time or recurring)You
InvoiceinvoiceIdThe invoice
  • A recurring custom item also needs billingPeriod (day, week, month, or year) and periodLength. billingPeriod: "day" with periodLength: 30 means 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 planId with label / unitPrice on the same item; that combination is rejected.
  • If a planId does not exist, or belongs to another merchant, create is rejected. We do not use label or unitPrice instead.
  • Prices are in major units: 10.0 means $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:

LimitValue
currencyOne of usd, eur, gbp, pln, chf, sek, dkk, nok, cad, aud
unitPriceAt most 100000
One line (unitPrice × quantity)At most 100000
Order total, today and on renewalAt most 100000 (order.amount_above_ceiling)
quantityAt most 10000 per line
periodLengthAt 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
}
]
}
FieldMeaning
statusopen, completed, or expired. open covers unpaid orders and the window after checkout confirm before the payment row is linked.
transactionIdInternal 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.
subscriptionIdInternal subscription id when the order has one recurring line item and one subscription is linked.
subscriptionIdsInternal subscription ids when the order has two or more recurring line items, or when multiple subscriptions are linked.
  • amountDue is what the buyer pays today, before tax. See Tax.
  • recurringAmount is what renews later, every periodLength × billingPeriod. It is null when nothing renews.
  • After the buyer pays, use transactionId from GET .../orders/{orderId} to load payment details from GET .../transactions/{transactionId}. Between checkout confirm and webhook linkage, transactionId stays null even 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).

checkout.html
<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: onTaxChange is called with calculated: true once tax is known. taxAmount is the tax and total is what the buyer pays today. calculated: false means tax is not known yet: keep showing amountDue without tax. That is not a tax of zero. When taxBehavior is inclusive, subtotal already includes the tax, so use total and do not add taxAmount on top.
  • Your own UI: POST /v1/checkout/orders/{orderId}/tax with the billing address, for example { "address": { "country": "US", "state": "NY", "postalCode": "10001" } }. The route is public and does not charge the buyer. Use taxAmount and total only when calculated is true. HTTP 422 with code checkout.tax_unavailable means 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​

  1. Up to 20 items per order.

  2. One billing schedule per order, and one trial length for all recurring items. Different schedules are rejected at create.

  3. One membership per order. Quantity on a recurring item multiplies its renewal (3 × $15/month = $45/month, one membership).

  4. One tax amount for the whole order.

  5. No promo codes.

  6. You cannot change an order. Create a new order instead.

  7. Custom items (you set the price) need an API key, so create orders from your server only.

  8. A planId or invoiceId from another merchant is rejected.

  9. Each paid amount must meet the currency minimum:

    CurrencyMinimum
    USD, EUR, GBP, CHF, CAD, AUD1
    PLN2
    DKK2.5
    SEK, NOK3

Order mode shows the same consent links and onError code values as Embedded checkout. Policy URLs come from GET /v1/checkout/orders/{orderId}.

Reference​

RouteAuthUse
POST /v1/public-api/merchants/{merchantId}/ordersAPI keyCreate an order (your server)
GET /v1/public-api/merchants/{merchantId}/orders/{orderId}API keyRead an order (your server)
GET /v1/checkout/orders/{orderId}NoneShow the items (web page)
GET /v1/checkout/orders/{orderId}/receipts/{receiptId}NoneShow 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​