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.completedwebhook — or a read ofGET /v1/api/payments/{id}— is the only signal you should trust to mark an order paid.
1. Create the payment link
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:
- Happy path — simulate a link with
"outcome": "completed". Confirm the webhook fires and your order flips to paid. - Failure path — simulate with
"outcome": "failed". Confirmpayment.failedfires and you show the right state (not silently stuckpending). - 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.
- Return-URL tampering — hit your own return URL with a hand-typed
soxara_status=completedand 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. - 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.