API ReferenceOverview

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.com

One 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-Key header (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:read for 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.

A one-time request to pay. Create one, show the customer the hosted checkout (https://checkout.soxara.com), and settle via webhook.

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:

ParameterValue
soxara_payment_idthe payment id
soxara_linkthe link code
soxara_statuscompleted, 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.

GET /v1/api/payment-links/{id}              scope: payments:read
GET /v1/api/payment-links/by-code/{code}    scope: payments:read

Simulate a payment (sandbox only)

POST /v1/api/payment-links/{id}/simulate    scope: payments:create

Test 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:read

Works 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:read

Your payments — link payments and mandate charges — newest first.

Param
statuspending | completed | failed (optional)
beforeISO-8601 cursor; returns rows created before it (keyset pagination)
limitdefault 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:read

Per-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 to approval_url (https://checkout.soxara.com/mandate/{id}) to save a card. The mandate activates when they do.
  • Wallet: returns a pending mandate + an approval_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 phone

Sends 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:read

Returns 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.

POST /v1/api/payment-links with "settlement": "hold". Requirements, all enforced with 422:

  • sub_merchant_id is set (the seller who will be paid at release);
  • amount is fixed (not a pay-any-amount link);
  • accepted_methods is a subset of ["wallet", "momo"] — card payments cannot be held yet, so you must pass accepted_methods explicitly (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 allowed

In 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 characters

Returns 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 | refunded

Webhooks

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:read

Gift 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:

CodeMeaning
FLEET_CARD_INACTIVEThe card isn’t active
CATEGORY_NOT_ALLOWEDThis category isn’t covered by the card
INSUFFICIENT_BALANCEThe card’s balance can’t cover the amount
SPEND_LIMIT_EXCEEDEDThis 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:create

See 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.