API Reference
The machine-to-machine API you integrate against with a merchant API
key. Everything lives under /v1/api/* and is
authenticated with your key as a Bearer token.
Base URL
https://api.soxara.comOne host for both modes — there is no separate sandbox host. Test vs. live
is decided by your key prefix (sxm_test_… vs sxm_live_…). See
Environments.
Conventions
- Auth:
Authorization: Bearer sxm_live_…on every request. - Content type:
application/json. - Amounts: integer minor units (
1200=USD 12.00). See Money. - Idempotency:
Idempotency-Keyheader (or a body field where noted) on every create. See Idempotency. - Envelopes:
{ "success": true, "data": … }or{ "success": false, "error": { "code", "message" } }. See Errors. - Field casing: request + response bodies are snake_case.
- Scopes:
payments:create/payments:readfor this section; sub-merchants, payroll, gift cards and fleet cards each have their own create/redeem and read scopes. See Scopes for the full list.
Payment links
A one-time request to pay. Create one, show the customer the hosted checkout
(https://checkout.soxara.com), and settle via webhook.
Create a payment link
POST /v1/api/payment-links scope: payments:create{
"title": "Order #1234", // required
"description": "…", // optional
"amount": 1200, // minor units; omit for a pay-any-amount link
"currency": "USD", // ISO-4217; one currency per link
"accepted_methods": ["card", "momo", "wallet"], // subset; default all three
"expires_at": "2026-07-10T18:00:00Z", // absolute timestamp, optional
"redirect_url": "https://shop.example.com/thanks?order=1234", // optional, see below
"metadata": { "order_id": "1234" }, // optional, echoed on the webhook
"sub_merchant_id": "…", // optional, see Sub-merchants
"settlement": "hold" // optional: "direct" (default) or "hold", see Held payments
}Returns the link, including link_code (e.g. PL-XXXXXXXX). Compose the payer
URL as https://checkout.soxara.com/{link_code}. There is no checkout_url
field. total_collected / payment_count reflect settlement — but use webhooks,
not polling, to react.
Sending the payer back to your site. Set redirect_url and hosted checkout
returns the payer to it after the payment finishes: a paid order goes back
automatically after about three seconds (the payer also sees a Return to
seller button); a failed or expired one only gets the button, so the payer can
retry first. Checkout adds three query parameters to your URL, keeping any you
already have:
| Parameter | Value |
|---|---|
soxara_payment_id | the payment id |
soxara_link | the link code |
soxara_status | completed, failed or expired |
The URL must be https:// and carry no credentials (http://localhost is
accepted on sxm_test_ keys only, for local development). Without redirect_url
the payer stays on the confirmation screen.
The redirect is for your customer’s browser, not for your books. Anyone can type
those parameters into a URL, so do not mark an order paid because the payer
landed on your page. Mark it paid from the payment.completed webhook, or look
the payment up with GET /v1/api/payments/{id}. A common pattern: the return
page shows “Confirming your payment…” until your webhook handler has recorded it.
Read a payment link
GET /v1/api/payment-links/{id} scope: payments:read
GET /v1/api/payment-links/by-code/{code} scope: payments:readSimulate a payment (sandbox only)
POST /v1/api/payment-links/{id}/simulate scope: payments:createTest links only (a live link 404s). Creates + settles a payment through the
exact same fan-out as production, so you receive a real webhook without real
money. Body (all optional): { "method": "card|momo|wallet", "outcome": "completed|failed", "amount": <minor units, pay-any links only> }.
Payments
Retrieve a payment
GET /v1/api/payments/{id} scope: payments:readWorks for both a link payment and a recurring charge. The status-by-id read for reconciliation or a missed webhook.
List payments (transaction history)
GET /v1/api/payments?status=&before=&limit= scope: payments:readYour payments — link payments and mandate charges — newest first.
| Param | |
|---|---|
status | pending | completed | failed (optional) |
before | ISO-8601 cursor; returns rows created before it (keyset pagination) |
limit | default 50, max 100 |
The response carries next_before (the last row’s created_at) — pass it back
as before for the next page. Scoped to the key’s environment.
Payment summary (dashboard metrics)
GET /v1/api/payments/summary?since= scope: payments:readPer-currency roll-up for dashboard tiles:
{ "summary": [
{ "currency": "USD",
"completed_count": 42, "completed_amount": 504000, // revenue
"failed_count": 3, "pending_count": 0, "total_count": 45 }
]}Recurring billing
Charge a customer each cycle without re-approval. A mandate is the standing authorization; a charge draws against it. Two rails:
- Card — the customer saves a card once on a hosted page (no Soxara account needed). Carries the card-processing fee (USD-only).
- Wallet — a Soxara wallet holder approves in-app with a PIN. Fee-free, multi-currency.
The typical flow: create a mandate at sign-up (customer approves once), then your
own scheduler calls POST /v1/api/charges each cycle and reacts to the
payment.completed / payment.failed webhook.
Create a mandate
POST /v1/api/mandates scope: payments:create{
"rail": "card", // "card" | "wallet"
"customer_ref": "your-user-id", // your id for this customer; echoed back
"currency": "USD", // card is USD-only; wallet allows USD/LRD
"max_amount_cents": 1200, // per-charge cap
"customer_email": "…" // optional (card receipts)
}- Card: returns
{ mandate, approval_url, setup_intent_client_secret }. Send the customer toapproval_url(https://checkout.soxara.com/mandate/{id}) to save a card. The mandate activates when they do. - Wallet: returns a
pendingmandate + anapproval_url(https://checkout.soxara.com/wallet-mandate/{id}) the customer opens in their Soxara app to approve with a PIN. - Test keys create an already-active mandate so you can exercise charges without the approval step.
Charge a mandate
POST /v1/api/charges scope: payments:create{
"mandate_id": "…",
"amount_cents": 1200, // must be ≤ the mandate's max_amount_cents
"currency": "USD",
"idempotency_key": "sub_42:2026-07" // your cycle key
}Returns the payment synchronously with its status, and fires the same
payment.completed / payment.failed webhook. A card decline / insufficient
wallet balance comes back as a failed payment (so you can dun). In a test
mandate, add "outcome": "completed|failed" to force the result.
Read / revoke a mandate
GET /v1/api/mandates/{id} scope: payments:read
DELETE /v1/api/mandates/{id} scope: payments:create (stops future charges)Sub-merchants
Provision a sub-account under your merchant — a department, campus, or branch with its own wallet — for a platform that wants to route specific payments to a specific child account rather than everything landing in one pool. Provisioning is a real OTP round-trip: your call kicks it off, but the sub-merchant’s own manager has to read back a live code sent to their phone. This can’t be skipped from the API.
Start provisioning a sub-merchant
POST /v1/api/sub-merchants/start scope: sub_merchants:create{ "manager_phone": "+231770000000" } // the sub-merchant manager's phoneSends an OTP to that phone. Returns { masked_phone, expires_in_seconds }.
Complete provisioning
POST /v1/api/sub-merchants/complete scope: sub_merchants:create{
"merchant_name": "Campus Store — West Wing",
"manager_phone": "+231770000000", // same phone as start
"otp": "482913" // the code the manager just read back
}On success, flips that phone’s account to a business account, creates the sub-merchant, and inherits the parent’s approved business KYC where one exists. Returns the new sub-merchant:
{ "id": "…", "merchant_name": "Campus Store — West Wing", "created_at": "…" }A sub-merchant cannot itself have sub-merchants — one level of hierarchy only.
List your sub-merchants
GET /v1/api/sub-merchants scope: sub_merchants:read{ "sub_merchants": [ { "id": "…", "merchant_name": "…", "created_at": "…" } ] }Routing a payment to a sub-merchant
Pass sub_merchant_id on payment link creation — the parent’s key still
authenticates the call, but the payment settles to the named sub-merchant’s wallet instead of the
parent’s.
Claims/settlement between parent and sub-merchant is dashboard bookkeeping, not exposed on this API — an integrator only needs to provision accounts and route payments to them.
Payroll
Machine-to-machine payroll disbursement — for an external HR/payroll system that computes gross-to-net and wants Soxara to move the money to each payee’s wallet. Payees are identified by phone number, not an internal staff id.
This never executes inline. A run you create here lands pending_approval; the actual business
owner approves it interactively, PIN-gated, the same way a wallet-mandate approval works. There is
no way to skip that step from the API — payroll moves money out of the merchant’s wallet, so a
server-to-server call alone can’t authorize it.
Create a payroll run
POST /v1/api/payroll/runs scope: payroll:create
Idempotency-Key: <your-key> (required — a plain header, distinct from
the gateway's own X-Idempotency-Key){
"currency": "USD", // USD or LRD
"note": "October payroll", // optional
"payments": [
{
"external_ref": "emp-1029", // your own id for this payee — required, used for correlation
"payee_phone": "+231770000001",
"label": "Adama Smith", // optional; defaults to external_ref
"amount_cents": 150000
}
]
}Up to 500 payments per run. Each payee phone is best-effort matched to a Soxara user; a miss
doesn’t reject the run — that line is marked needs_attention at execution time instead.
Returns:
{
"run": { "id": "…", "status": "pending_approval", "currency": "USD", "…": "…" },
"payments": [ { "external_ref": "emp-1029", "status": "pending", "…": "…" } ],
"approval_url": "https://checkout.soxara.com/payroll-approval/{run_id}"
}Send approval_url to whoever at the merchant approves payroll — that’s where the PIN step-up
happens. Re-posting the same Idempotency-Key returns the existing run instead of creating a
duplicate.
Read a payroll run
GET /v1/api/payroll/runs/{id} scope: payroll:readReturns the run plus every payment line, so you can react to payroll_run.completed /
payroll_run.partial / payroll_run.failed webhooks and reconcile line-by-line via
external_ref.
Held payments (marketplace protection)
A platform selling on behalf of its sellers can hold the buyer’s money until the goods are confirmed, then release it to the seller (minus the platform’s fee) or refund the buyer. The platform never holds the money itself: it sits in a Soxara-controlled hold wallet, and the platform can only ask Soxara to release or refund payments of its own sub-merchants.
Availability. Off by default. A hold link is refused (422) until Soxara has enabled held
payments and provisioned the hold wallet for the link’s currency. Test keys (sxm_test_) work
without any of that and move no money, so you can build and test the whole flow first.
Create a held link
POST /v1/api/payment-links with "settlement": "hold". Requirements, all enforced with 422:
sub_merchant_idis set (the seller who will be paid at release);amountis fixed (not a pay-any-amount link);accepted_methodsis a subset of["wallet", "momo"]— card payments cannot be held yet, so you must passaccepted_methodsexplicitly (the default includes card).
The payer pays exactly as for any link. When the payment completes you receive payment.completed
as usual; the data.link.settlement field says "hold". The merchant’s share
(net_amount) is now held. The seller is not credited yet.
Release
POST /v1/api/payments/{id}/release scope: payments:release
X-Idempotency-Key: <your-key>{ "platform_fee_cents": 200 } // what you keep from this payment; 0 is allowedIn one step the seller is credited held amount − platform_fee_cents and you are credited the fee.
platform_fee_cents is capped by Soxara (5% of the held amount by default); above the cap the call
fails with 422 FEE_ABOVE_CAP and nothing moves. Returns the hold:
{ "payment_id": "…", "status": "released", "amount_cents": 10000, "currency": "USD",
"platform_fee_cents": 200, "seller_amount_cents": 9800, "replayed": false, … }Refund
POST /v1/api/payments/{id}/refund scope: payments:release
X-Idempotency-Key: <your-key>{ "reason": "goods never arrived" } // optional, up to 500 charactersReturns the held amount to the payer’s Soxara wallet. Today this works only when the payer paid
from a Soxara wallet. A payer who paid by mobile money has no wallet to receive it, so the call fails
with 409 REFUND_DESTINATION_UNAVAILABLE and the money simply stays held (it can still be
released); contact Soxara support to resolve those. The customer fee Soxara charged on top of the
payment is not returned, as with any refund.
Safe to repeat
Both calls can be repeated with the same request. A completed one returns the same result with
"replayed": true and moves no money; one that stopped halfway (for example a wallet timeout,
503 WALLET_UNAVAILABLE) continues where it stopped. Once released a payment cannot be refunded and
the other way round (409 INVALID_STATE). A release repeated with a different fee is refused
(409 ALREADY_RELEASED / 409 FEE_MISMATCH).
Read
GET /v1/api/payments/{id}/hold scope: payments:read
GET /v1/api/holds?status=&limit= scope: payments:read // status: held | releasing | released | refunding | refundedWebhooks
payment.released and payment.refunded are sent when the money has moved, with the hold under
data.hold. Subscribe to them like any other event.
Payouts
Disburse from a merchant’s wallet to a MTN/Orange number — for an integration that needs to send collected funds on to a third party (a school-fees portal paying a school, for example). This is deliberately not “send to any number”: a payout can only go to a payee the business owner already PIN-approved from the dashboard (Settings → Developers → Payout payees), each with its own per-payout and daily caps. There’s no way from this API to authorize a new destination or raise a cap — that always happens interactively, with a PIN, on the dashboard.
Create a payout
POST /v1/api/payouts scope: payouts:create
X-Idempotency-Key: <your-key>{
"payee_id": "…", // an id from the dashboard's payee list
"amount_cents": 150000,
"currency": "USD", // must match the payee's own currency
"external_ref": "fee-batch-2026-09" // optional, your own correlation id
}Rejects with 422 VALIDATION_ERROR if the payee is revoked, the currency doesn’t match, the amount
exceeds the payee’s per-payout cap, or today’s total to that payee would exceed its daily cap.
Returns immediately with the payout’s initial state — processing, completed, or failed
depending on how quickly the provider responds:
{
"id": "…",
"payee_id": "…",
"amount_cents": 150000,
"currency": "USD",
"status": "processing",
"external_ref": "fee-batch-2026-09",
"initiated_at": "…"
}A processing payout resolves on its own — poll GET /v1/api/payouts/{id} for the final
completed/failed state; there’s no payout webhook event yet, so polling is the only signal
today. A wallet debit happens the instant the payout is accepted, same convention as MoMo
withdrawal — a failed payout means the debit was reversed, not that it never happened.
Read a payout
GET /v1/api/payouts/{id} scope: payouts:read
GET /v1/api/payouts?limit=&offset= scope: payouts:readGift cards
POS redemption for a Soxara gift card. Not under /v1/api/* — it lives at its own top-level
path.
POST /v1/gift-cards/redeem scope: gift_cards:redeem{
"merchant_wallet_id": "…", // the customer's per-merchant pocket — what a POS QR encodes
"amount": 500, // minor units of the wallet's currency
"reference": "receipt-8831", // optional, your own reference
"idempotency_key": "pos-8831", // required — body field, not a header
"location_id": "…" // optional, multi-location reporting only
}Returns the redemption transaction and the wallet’s resulting state. A retry with the same
idempotency_key returns the original result rather than redeeming twice.
Fleet cards
POS/pump redemption for a Soxara fleet card, with category and spend-limit enforcement.
Also not under /v1/api/*.
POST /v1/fleet-cards/redeem scope: fleet_cards:redeem{
"fleet_card_id": "…",
"category": "fuel", // must be one the card allows
"amount_cents": 5000,
"reference": "pump-4", // optional
"idempotency_key": "pos-4471", // required — body field, not a header
"location_id": "…" // optional; ignored if your key is already station-scoped
}Rejections come back 422 with a code specific to why:
| Code | Meaning |
|---|---|
FLEET_CARD_INACTIVE | The card isn’t active |
CATEGORY_NOT_ALLOWED | This category isn’t covered by the card |
INSUFFICIENT_BALANCE | The card’s balance can’t cover the amount |
SPEND_LIMIT_EXCEEDED | This redemption would exceed the card’s period spend limit |
A station-scoped API key stamps its own location automatically — most pump/POS integrations never
need to send location_id at all.
Webhooks
Register where Soxara POSTs events, with the same key you pay with.
POST /v1/api/webhook-endpoints scope: payments:create (returns the secret ONCE)
GET /v1/api/webhook-endpoints scope: payments:read
DELETE /v1/api/webhook-endpoints/{id} scope: payments:createSee Webhooks for the payload shape, signature verification, and the event list.
What this API does not do
- Card issuance / KYC — your customers do these inside Soxara’s own surfaces.
- Consumer wallet/transfer/bill/membership APIs — those are Soxara’s own JWT-authenticated app surfaces, not the merchant key surface.
- Refunds — not exposed on the merchant API surface today; a refund is issued from the business dashboard. The one exception is returning a held payment to its payer (Held payments).
- Gift card issuance — gift cards are issued from the business dashboard; the API surface covers redemption only.
- Payroll execution without a human approval — see Payroll above; a run created
via the API always lands
pending_approval.
OpenAPI
A generated OpenAPI 3.1 spec is on the roadmap. Until then, this page is the source of truth; ping [email protected] for any shape you can’t find here.