Run your own checkout: quotes, sessions and payment
Build a point-of-sale checkout on the Partner API: price a cart, open a session, take the card in your own page, then pay, retry or cancel safely.
Sell a device and a plan from your own site, app or till, with your own checkout page in front. The Partner API prices the cart, opens the sale and charges the card; you decide what your customer sees.
If you haven't made a sale through the API yet, start with From a new key to your first order. This guide goes deeper on each step and on what to do when something goes wrong.
How a sale flows
- Price the cart with POST/v1/quotes as often as it changes.
- Open a checkout session with POST/v1/checkout_sessions. Nothing is charged.
- Fetch GET/v1/payment_config and collect the card in your page for exactly the session's
totals.due_now. - Charge it with POST/v1/checkout_sessions/{id}/actions/pay.
- If the customer walks away instead, cancel with POST/v1/checkout_sessions/{id}/actions/cancel.
Check where a sale stands at any time with GET/v1/checkout_sessions/{id}. Its status is one of these:
status | What it means | Can you pay it? |
|---|---|---|
awaiting_payment | Opened and priced, not charged yet | Yes |
payment_failed | The last card was declined | Yes, with a new card |
paid | Charged, being handed to fulfillment | No |
submitted | Charged and with fulfillment. Done. | No |
submit_failed | Charged; the handoff to fulfillment is being retried for you | No — don't charge again |
cancelled | Abandoned | No |
Each call needs its own scope on your key:
| Call | Scope |
|---|---|
| POST/v1/quotes | quotes:write |
| GET/v1/payment_config, POST/v1/checkout_sessions, POST/v1/checkout_sessions/{id}/actions/pay, POST/v1/checkout_sessions/{id}/actions/cancel | orders:write |
| GET/v1/checkout_sessions/{id} | orders:read |
Your account also needs the Order module with retail sales turned on.
Price the cart
POST/v1/quotes takes the same packages you'll send at checkout and creates nothing — no order, no customer, no charge. Call it on every cart change.
Each package is one device, with an optional plan and add-ons:
device.product_idcomes from GET/v1/products;plan.plan_idfrom GET/v1/plans. Add-ons take product ids too.device.billingispurchase(the default) oreaas, which rents the hardware and needs a plan. Leased products bill monthly with their plan whatever you send.device.sourceiswarehouse(the default), which ships a unit from stock, orprovidedfor equipment your customer already has.- A package with a plan has a device
quantityof 1. Selling three routers on three plans? Send three packages.
Read the quote's lines by kind. item is something you asked for; fee and shipping are added by the billing platform; eaas_fee is the rental fee on a rented device. activation lines are not in due_now — they bill from the day the device is activated. That's how a plan on warehouse hardware is billed, except on a leased device: its plan and lease bill today.
Equipment the customer already has
Set source to provided and send the device's serial (or, for a SIM, "kind": "sim" and its iccid). It needs a plan, and its plan bills today rather than at activation. You're never charged for the hardware.
{
"package_id": "pkg_2",
"device": { "product_id": "1", "source": "provided", "serial": "M11-2416002345" },
"plan": { "plan_id": "12" }
}Serials for some models are checked against their format: a MergeWiFi Max serial looks like MR40-A1- followed by eight digits. Each serial or ICCID can appear once per basket.
Open the session
POST/v1/checkout_sessions takes the customer, a shipping_address and the packages, and returns the sale with its authoritative total. It creates the customer's billing account if they don't have one and registers any equipment they already hold.
curl https://cloud.mergewifi.com/v1/checkout_sessions \
-X POST \
-H "Authorization: Bearer $MERGEWIFI_API_KEY" \
-H "Idempotency-Key: 6f1c2a1e-3d0b-4b7e-9a51-0c7d2f8e4b19" \
-H "Content-Type: application/json" \
-d '{
"customer": { "first_name": "Dana", "last_name": "Reyes", "email": "dana@example.com", "phone": "5125550142" },
"shipping_address": { "line1": "100 Congress Ave", "city": "Austin", "state": "TX", "postal_code": "78701" },
"packages": [
{
"package_id": "pkg_1",
"device": { "product_id": "1", "quantity": 1, "billing": "purchase", "source": "warehouse" },
"plan": { "plan_id": "12" },
"addons": []
}
]
}'{
"object": "checkout_session",
"id": "3b1e77c2-9a4d-4f81-8c2e-77a90b1d5e64",
"status": "awaiting_payment",
"totals": { "subtotal": "379.00", "tax": "31.27", "total": "412.87", "due_now": "412.87", "recurring_monthly": "49.99" },
"payment_method": null
}- Idempotency-Key is required. Use a fresh UUID per sale and keep it with the cart. Sending the same key and basket again returns the original session with a
200, so a timeout is always safe to retry. Reusing a key for a different basket is refused. See Retries and duplicates. - Selling to someone who's bought before? Pass their
customer_idfrom Look up your customers. It binds the sale to their existing billing account instead of matching on email. - Phone numbers are saved to the billing account when they're a 10-digit US number. An existing account's number is only filled in if it's empty, never replaced.
notes(up to 2,000 characters) travels with the order.
Take the card in your own page
The billing platform only accepts a single-use card nonce, and only the payment gateway's browser SDK can make one. So the card is tokenized in your page, against our gateway, and card details never reach your servers or ours.
- From your backend, call GET/v1/payment_config.
- Pass
client_keyandapi_login_idto your checkout page. Both are publishable and safe in a browser. - In the page, hand them to the gateway SDK as
clientKeyandapiLoginID, with the card details your customer enters. - The SDK returns
opaqueData. Send itsdataValueanddataDescriptorto your backend.
curl https://cloud.mergewifi.com/v1/payment_config \
-H "Authorization: Bearer $MERGEWIFI_API_KEY"{
"object": "payment_config",
"client_key": "9Aq2Xk7fPd4tYb6R…",
"api_login_id": "5KpN3xW8",
"payment_type_id": 7,
"environment": "production"
}The response's gateway field names the gateway whose SDK to load, and environment says whether it's production or sandbox. Keep payment_type_id for the next step.
Pay, retry or cancel
Charge the session with POST/v1/checkout_sessions/{id}/actions/pay. This is the call that moves money.
curl https://cloud.mergewifi.com/v1/checkout_sessions/3b1e77c2-9a4d-4f81-8c2e-77a90b1d5e64/actions/pay \
-X POST \
-H "Authorization: Bearer $MERGEWIFI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payment": {
"nonce": "eyJjb2RlIjoiNTBfMl8wNjAwMDUyN0…",
"data_descriptor": "COMMON.ACCEPT.INAPP.PAYMENT",
"payment_type_id": 7
},
"expected_total": "412.87"
}'nonce is opaqueData.dataValue, data_descriptor is opaqueData.dataDescriptor, and expected_total is the session's totals.due_now. On success the session comes back submitted, with the card's brand and last4.
Declined? You get a 402 and the session moves to payment_failed, still payable. Ask for another card, mint a fresh nonce and pay again. A nonce works once — never store or reuse one.
Total changed? If expected_total no longer matches, the charge is refused with a 409 and a message naming the new amount. Read the session again with GET/v1/checkout_sessions/{id}, show the customer the new total, tokenize for that amount and pay.
Timed out? Don't guess. Read the session with GET/v1/checkout_sessions/{id}. If it's paid, submitted or submit_failed, the money moved. Calling pay again on a session that's already charged just returns it as it is, so a blind retry can't charge twice.
Customer walked away? Cancel with POST/v1/checkout_sessions/{id}/actions/cancel. It cancels the draft order behind the session, which keeps your records tidy. Only unpaid sessions can be cancelled: once the card is charged, cancel returns 409, and refunds aren't available through the API.
When something goes wrong
Every error has the same shape: a type, a stable code, a readable message and a request_id. Branch on code, and quote the request_id if you contact us. See Errors.
| Status | code | What happened | What to do |
|---|---|---|---|
400 | invalid_request | A field is wrong. param names it, like packages[0].plan.plan_id. | Fix it and send again. |
402 | payment_failed | The card was declined. | New card, fresh nonce, pay again. |
409 | invalid_state_transition | The total moved, or the session can't do that any more. | Read the session, then act on its status. |
409 | billing_rejected | The billing platform refused the sale itself — say, a serial already on a live line. | Change the basket. Retrying won't help. |
503 | service_unavailable | Something upstream is briefly unavailable. | Wait for Retry-After, then retry. |
Related: From a new key to your first order · Look up your customers · Track orders and the devices they ship · Going live

