Docs

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.

Guide6 min readUpdated November 6, 2026

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

  1. Price the cart with POST/v1/quotes as often as it changes.
  2. Open a checkout session with POST/v1/checkout_sessions. Nothing is charged.
  3. Fetch GET/v1/payment_config and collect the card in your page for exactly the session's totals.due_now.
  4. Charge it with POST/v1/checkout_sessions/{id}/actions/pay.
  5. 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:

statusWhat it meansCan you pay it?
awaiting_paymentOpened and priced, not charged yetYes
payment_failedThe last card was declinedYes, with a new card
paidCharged, being handed to fulfillmentNo
submittedCharged and with fulfillment. Done.No
submit_failedCharged; the handoff to fulfillment is being retried for youNo — don't charge again
cancelledAbandonedNo

Each call needs its own scope on your key:

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_id comes from GET/v1/products; plan.plan_id from GET/v1/plans. Add-ons take product ids too.
  • device.billing is purchase (the default) or eaas, which rents the hardware and needs a plan. Leased products bill monthly with their plan whatever you send.
  • device.source is warehouse (the default), which ships a unit from stock, or provided for equipment your customer already has.
  • A package with a plan has a device quantity of 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_id from 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.

  1. From your backend, call GET/v1/payment_config.
  2. Pass client_key and api_login_id to your checkout page. Both are publishable and safe in a browser.
  3. In the page, hand them to the gateway SDK as clientKey and apiLoginID, with the card details your customer enters.
  4. The SDK returns opaqueData. Send its dataValue and dataDescriptor to 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.

StatuscodeWhat happenedWhat to do
400invalid_requestA field is wrong. param names it, like packages[0].plan.plan_id.Fix it and send again.
402payment_failedThe card was declined.New card, fresh nonce, pay again.
409invalid_state_transitionThe total moved, or the session can't do that any more.Read the session, then act on its status.
409billing_rejectedThe billing platform refused the sale itself — say, a serial already on a live line.Change the basket. Retrying won't help.
503service_unavailableSomething 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