Skip to main content

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​

  1. Web page: the buyer clicks Pay. The embed sends a confirmationToken to your confirm endpoint.
  2. Your server: loads the price you saved, and charges the buyer with your API key.
  3. Your server: returns the charge result (paymentId, clientSecret, status) to the embed.
  4. 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.

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

checkout.html
<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 } in mount() options. This saves the card for later charges.
  • On your server: the same recurring object 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 wrongWhat you see
Wrong merchantId, or the merchant is in the other environmentmount() rejects (status 404 in the message)
The merchant is not set up to take payments yetmount() rejects
confirmUrl is on another originmount() rejects
The buyer clicked Pay before filling in address or cardsubmit() rejects
Your endpoint returned an error statussubmit() rejects, and onError is called
The payment failed, for example a declined cardsubmit() rejects, and onError is called

Go live​

  1. On your server, use a pai_live_* key and https://mor.payments.ai/api.
  2. On your web page, set environment to 'live'.

See Sandbox vs live.

Reference​

mount(element, options)​

OptionRequiredDescription
merchantIdYesYour merchant ID.
amountYesAmount in major units, for example 59.00. Use the amount you saved on the server.
currencyYesCurrency code, for example usd.
confirmUrlYesYour confirm endpoint. Same origin as the page.
environmentYes'sandbox' or 'live'. Must match your server's API key.
recurringNo{ amount, intervalDays, trialDays? }. See Charge again later.
payerTypeNo'individual' (default) or 'business'. 'business' adds a tax ID field.
emailNoPrefills the email field when shown, or supplies email when hideBuyerFields is true.
billingDetailsNoPrefills or supplies billing name and address. See Embedded checkout — Prefill buyer details.
hideBuyerFieldsNoHides email and address fields when you pass complete buyer details.
returnUrlNoWhere the buyer comes back to after a bank check on another page. Defaults to the current page.
onCompleteNoCalled with { merchantId, amount, currency, receiptId } after a successful payment.
onErrorNoCalled with { message, code } when a payment fails. See Embedded checkout — Handle errors.
onReadyNoCalled 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​

DirectionBody
Embed → your server{ confirmationToken, returnUrl }
Your server → embed{ paymentId, clientSecret, status } from the charge

Next steps​