Custom amount checkout
Use this when your code works out the price, for example from a quantity picker or a custom package, and you do not want to create a plan for it. You need one server endpoint.
Selling a plan you already created? Use Embedded checkout, which needs no server code. Several items in one payment? Use Order checkout.
How it works
- Web page: the buyer clicks Pay. The embed sends a
confirmationTokento your confirm endpoint. - Your server: loads the price you saved, and charges the buyer with your API key.
- Your server: returns the charge result (
paymentId,clientSecret,status) to the embed. - Web page: the embed runs any bank check (such as 3-D Secure), then calls your
onComplete.
The browser never sees your API key and never decides the price. Your server does both.
On your server
1. Save the price before the page loads
When the buyer builds their cart, save the amount and currency on your server, for example on the session or on a cart row. The confirm endpoint reads it from there. Never charge an amount sent by the browser: a buyer can change it.
2. Add a confirm endpoint
The embed POSTs { confirmationToken, returnUrl } to this endpoint, with the page's cookies.
Your endpoint charges the buyer and returns the Payments AI response as it is.
app.post('/api/checkout/confirm', async (req, res) => {
const { confirmationToken, returnUrl } = req.body;
const cart = await loadCart(req.session.cartId); // the price you saved in step 1
const charge = await fetch(
`https://sandbox.mor.payments.ai/api/v1/public-api/merchants/${MERCHANT_ID}/transactions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAI_API_KEY}`, // pai_test_* in sandbox
'Content-Type': 'application/json',
'Idempotency-Key': confirmationToken, // a retried request never charges twice
},
body: JSON.stringify({
amount: cart.amount, // major units: 59.00 means $59.00
currency: cart.currency, // lowercase, for example 'usd'
confirmationToken,
returnUrl,
}),
},
);
// Success returns { paymentId, clientSecret, status }. The embed needs all three.
res.status(charge.status).json(await charge.json());
});
The charge route is POST /v1/public-api/merchants/{merchantId}/transactions. It also takes
an optional description and metadata. See the API Reference.
On your web page
3. Mount the payment fields
Pass the same amount and currency you saved on the server. The embed uses them to set up the payment fields. Your server decides what is charged.
<div id="summary">Total: $59.00</div>
<div id="checkout"></div>
<button id="pay" disabled>Pay $59.00</button>
<p id="message"></p>
<script src="https://js.managed.payments.ai/v1/checkout.js"></script>
<script>
function showMessage(text) {
document.getElementById('message').textContent = text;
}
async function start() {
const checkout = await PaymentsAICheckout.mount(document.getElementById('checkout'), {
merchantId: 'YOUR_MERCHANT_ID',
amount: 59.0,
currency: 'usd',
confirmUrl: '/api/checkout/confirm', // your endpoint from step 2
environment: 'sandbox',
onComplete: ({ receiptId }) => showMessage(`Paid. Receipt ${receiptId}`),
});
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>
confirmUrl must be on the same origin as the page. The embed rejects any other URL.
onComplete gives you { merchantId, amount, currency, receiptId }. receiptId is the
paymentId your server got back from the charge.
Charge again later (subscriptions)
To bill the buyer again later, send recurring in both places with the same values:
- On the web page:
recurring: { amount: 40.0, intervalDays: 7 }inmount()options. This saves the card for later charges. - On your server: the same
recurringobject in the charge body.
recurring takes amount, intervalDays, and an optional trialDays.
Handle errors
Consent links and stable onError code values work the same as in
Embedded checkout. Amount mode loads policy URLs from
GET /v1/checkout/merchants/{merchantId}/config.
| What went wrong | What you see |
|---|---|
Wrong merchantId, or the merchant is in the other environment | mount() rejects (status 404 in the message) |
| The merchant is not set up to take payments yet | mount() rejects |
confirmUrl is on another origin | mount() rejects |
| The buyer clicked Pay before filling in address or card | submit() rejects |
| Your endpoint returned an error status | submit() rejects, and onError is called |
| The payment failed, for example a declined card | submit() rejects, and onError is called |
Go live
- On your server, use a
pai_live_*key andhttps://mor.payments.ai/api. - On your web page, set
environmentto'live'.
See Sandbox vs live.
Reference
mount(element, options)
| Option | Required | Description |
|---|---|---|
merchantId | Yes | Your merchant ID. |
amount | Yes | Amount in major units, for example 59.00. Use the amount you saved on the server. |
currency | Yes | Currency code, for example usd. |
confirmUrl | Yes | Your confirm endpoint. Same origin as the page. |
environment | Yes | 'sandbox' or 'live'. Must match your server's API key. |
recurring | No | { amount, intervalDays, trialDays? }. See Charge again later. |
payerType | No | 'individual' (default) or 'business'. 'business' adds a tax ID field. |
email | No | Prefills the email field when shown, or supplies email when hideBuyerFields is true. |
billingDetails | No | Prefills or supplies billing name and address. See Embedded checkout — Prefill buyer details. |
hideBuyerFields | No | Hides email and address fields when you pass complete buyer details. |
returnUrl | No | Where the buyer comes back to after a bank check on another page. Defaults to the current page. |
onComplete | No | Called with { merchantId, amount, currency, receiptId } after a successful payment. |
onError | No | Called with { message, code } when a payment fails. See Embedded checkout — Handle errors. |
onReady | No | Called once the payment fields are mounted. |
mount() returns the same controls as Embedded checkout:
submit(), setEmail(email), and destroy().
Custom amount checkout has no onTaxChange: tax is not previewed before payment, because there
is no catalogue plan to price it against. Tax still follows your merchant default tax behaviour
(exclusive adds tax on top of amount; inclusive treats amount as tax-inclusive). Receipt
responses include taxBehavior with subtotal, taxAmount, and total. Change the default with
GET / PATCH /v1/public-api/merchants/{merchantId}/settings/tax (Merchant API key) or the
dashboard route /v1/merchants/{merchantId}/settings/tax.
Your confirm endpoint
| Direction | Body |
|---|---|
| Embed → your server | { confirmationToken, returnUrl } |
| Your server → embed | { paymentId, clientSecret, status } from the charge |
Next steps
- Order checkout: several items in one payment
- Merchant Public API: API keys and server routes
- API Reference: the charge route in full