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