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
}

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.


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