GuidesAccept a payment

Accept a payment

How to take a customer payment end-to-end with a payment link and Soxara’s hosted checkout — card, MoMo, or wallet, through one flow you don’t build any UI for.

The flow

┌──────────┐  1. Create a payment link          ┌──────────┐
│ Your     │───────────────────────────────────▶│ Soxara   │
│ server   │◀───────────────────────────────────│ API      │
└──────────┘  2. link_code                       └──────────┘
     │
     │ 3. Redirect the customer's browser to
     │    checkout.soxara.com/{link_code}
     ▼
┌──────────┐  4. Customer pays with card, MoMo,  ┌──────────┐
│ Hosted   │     or their Soxara wallet — you    │ Soxara   │
│ checkout │     never see the payment details   │ checkout │
└──────────┘                                     └────┬─────┘
                                                       │
                          5. payment.completed webhook │
┌──────────┐◀──────────────────────────────────────────┘
│ Your     │
│ webhook  │
└──────────┘

Two key principles:

  • Server creates the payment link. Never let the customer’s browser pick the amount — the link is your declaration of “this order costs $X,” created with your secret key, server-side.
  • Webhook is the source of truth. Not the browser landing back on your site. A customer can close the tab, lose connectivity, or type the return URL’s query params in by hand. The payment.completed webhook — or a read of GET /v1/api/payments/{id} — is the only signal you should trust to mark an order paid.

On your server, when the customer is ready to pay:

curl -X POST "$SOXARA_BASE/v1/api/payment-links" \
  -H "Authorization: Bearer $SOXARA_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: order-${ORDER_ID}-v1" \
  -d '{
    "title": "Order #'${ORDER_ID}'",
    "amount": 1250,
    "currency": "USD",
    "accepted_methods": ["card", "momo", "wallet"],
    "redirect_url": "https://yoursite.com/orders/'${ORDER_ID}'/thanks",
    "metadata": { "order_id": "'${ORDER_ID}'" }
  }'

metadata is yours — Soxara stores it but doesn’t act on it. It’s echoed back on the payment.completed webhook so you can thread your own order id through.

Response:

{
  "success": true,
  "data": {
    "id": "…",
    "link_code": "PL-A1B2C3D4",
    "title": "Order #1234",
    "amount": 1250,
    "currency": "USD",
    "status": "pending"
  }
}

Save link_code against your order. Compose the payer URL yourself — https://checkout.soxara.com/{link_code} — there is no separate checkout_url field in the response.

2. Send the customer to hosted checkout

Redirect the customer’s browser to https://checkout.soxara.com/{link_code}. From there Soxara runs the whole payment experience — card entry, MoMo phone + PIN prompt, or a Soxara wallet PIN approval for an existing user — and you never see card numbers, MoMo PINs, or anything else that would put you in PCI/credential-handling scope.

With redirect_url set, checkout sends the customer back to it once the payment finishes: a paid order returns automatically after a few seconds (with a Return to seller button too); a failed or expired one only gets the button, so the customer can retry. Checkout adds soxara_payment_id, soxara_link, and soxara_status (completed/failed/expired) as query parameters — see the reference for the full list. Anyone can type those parameters into a URL by hand, so don’t mark an order paid because the browser landed here — that’s what the webhook (step 3) is for.

3. Mark the order paid via webhook

In your webhook handler (see Handle a webhook), wait for payment.completed:

if (event.type === 'payment.completed') {
  const payment = event.data.payment;
  const orderId = event.data.link?.metadata?.order_id;
 
  // Idempotent on Soxara-Delivery / the payment's own idempotency_key — see
  // /guides/handle-webhook for the dedupe layer.
  await db.query(
    `UPDATE orders
        SET status = 'paid', paid_at = NOW(), payment_id = $1
      WHERE id = $2 AND status IN ('pending', 'awaiting_payment')`,
    [payment.id, orderId],
  );
}

The WHERE status IN (...) guard prevents a webhook processed out of order from flipping a manually-canceled order back to “paid.”

4. Show the receipt

After checkout redirects the customer back (or they land on your own “thanks” page), don’t make business decisions on the frontend. Look the order up in your own database and render whatever you’ve already recorded from the webhook:

// frontend
window.location = `/orders/${orderId}/thanks`;

On the thanks page: if the webhook has already landed, status is paid — show a receipt. If it hasn’t yet (network slow), status is awaiting_payment — show “confirming your payment…” and poll your own backend, or reload once the webhook lands.

What to test

Before going live, walk through this with an sxm_test_ key — see Test with the sandbox:

  1. Happy path — simulate a link with "outcome": "completed". Confirm the webhook fires and your order flips to paid.
  2. Failure path — simulate with "outcome": "failed". Confirm payment.failed fires and you show the right state (not silently stuck pending).
  3. Idempotent retry — replay the same delivery through your handler (the dashboard’s redeliver button, or just call your handler twice with the same payload). Confirm your order doesn’t get double-processed.
  4. Return-URL tampering — hit your own return URL with a hand-typed soxara_status=completed and no real payment behind it. Confirm your thanks page still shows “confirming” rather than “paid,” because it’s reading your DB, not the query string.
  5. Recurring billing, if you use it — see mandates and charges for the separate flow (a mandate approved once, charged on your own schedule).

Test all of these in sandbox before swapping in your sxm_live_ key.