CentryCentry|Merchant API Docs
API ReferenceGitHub Dashboard
Getting Started
  • Introduction
  • Quick Start
  • Authentication
  • Environments
API Keys
  • Key Format
  • Permissions
  • Country Scoping
  • IP Whitelisting
  • Spend Caps
Provider Accounts
  • How They Work
  • Country Auto-Linking
  • Permissions
Payins
  • Create Payin
  • List Payins
  • Get Payin
  • Complete Payin
Payouts
  • Create Payout
  • Bulk Payouts
  • Bank Transfers
Balance
Fees
ERP Bridge
Idempotency
Webhooks
  • Events
  • Verification
Errors
Rate Limits
OpenAPI / Swagger
Going Live
Security
Merchant API v1

Payins, Payouts & Balances

One unified API for collections and disbursements across 10+ African countries. Centry handles provider routing, fee calculation, balance tracking, and ERP sync.

Quick Start

5 min setup

Payins

Collections

Payouts

Disbursements

Webhooks

Payment events

Reference

Country & gateway codes

Introduction

How Centry Merchant API works

The Centry Merchant API gives you programmatic access to payins (money in) and payouts (money out) across multiple African payment providers. Each transaction automatically calculates fees, updates your balance, and optionally syncs to your ERP system.

How it works

01

Open direct provider accounts

We set up accounts in your name with your preferred payment provider or bank — in any country you operate in. No intermediaries holding your funds.

02

Plug into your ERP

Connect Xero, QuickBooks, or SAP in minutes. Invoices, bills, contacts, and chart of accounts stay in sync both ways.

03

Choose how you accept payments

Integrate directly via our REST API and SDKs for full control, drop in a Centry-hosted shop or checkout page for a same-day launch with no frontend work, or run everything from the Centry Merchant dashboard — send payment links, record invoices, and approve payouts without writing a line of code.

04

Transact and reconcile in real time

Collect on invoices and pay supplier bills the moment they're created in your ERP. Each settlement is matched back to the source document automatically — no month-end spreadsheet chase.

Quick Start

Your first payin in under 5 minutes

Need a country, gateway, or provider code?

Browse the live API reference for every supported country_code, gateway, and provider value — one click to copy.

Open →

1. Get an API key

Ask your Centry admin to create a key for your merchant. Sandbox keys start with mk_test_ and production keys with mk_live_.

2. Create a payin

Each provider needs a slightly different payload. Pick the tab for the gateway you're using, swap in your API key, and it runs. Set create_invoice: true only once you've connected an ERP — otherwise leave it false.

Prereqs: your merchant must have a Provider Account configured for the country you're targeting. Check the merchant page → Provider Accounts tab.
curl -X POST https://api.getcentry.io/v1/payins \
-H "Authorization: Merchant-Key mk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"country_code": "UG",
"currency_code": "UGX",
"amount": 50000,
"payment_method": "mobile_money",
"gateway": "mtn",
"customer_name": "Jane Doe",
"customer_phone": "+256700000000",
"reference": "ORDER-1234",
"create_invoice": false
}'
Python (requests)
import requests
resp = requests.post(
"https://api.getcentry.io/v1/payins",
headers={"Authorization": "Merchant-Key mk_test_xxx"},
json={
"country_code": "UG",
"currency_code": "UGX",
"amount": 50000,
"payment_method": "mobile_money",
"gateway": "mtn",
"customer_name": "Jane Doe",
"customer_phone": "+256700000000",
"reference": "ORDER-1234",
"create_invoice": False,
},
)
payin = resp.json()
print(payin["id"], payin["status"])
Python SDK (pip install pycentry)

Package

Distributed as pycentry on PyPI — imports as from centry import Client. Source: centry-sdk-python/.

Install (production)

pip install pycentry

Install from TestPyPI (release candidates)

pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
pycentry

The --extra-index-url fallback lets pip resolve requests and other deps from the real PyPI while pulling pycentry from TestPyPI.

Pin a specific version

pip install 'pycentry==0.1.1'
pypi.org/project/pycentry →test.pypi.org/project/pycentry →
from centry import Client
client = Client(merchant_key="mk_test_xxx")
payin = client.payins.create(
country_code="UG",
currency_code="UGX",
amount=50000,
payment_method="mobile_money",
gateway="mtn",
customer_name="Jane Doe",
customer_phone="+256700000000",
reference="ORDER-1234",
create_invoice=False,
)
print(payin.id, payin.status, payin.net_amount)

3. Response

Response — 201 Createdjson
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"amount": "50000.00",
"currency_code": "UGX",
"payment_method": "mobile_money",
"gateway": "mtn_momo",
"centry_fee": "250.00",
"gateway_fee": "500.00",
"fee_amount": "750.00",
"net_amount": "49250.00",
"status": "pending",
"reference": "ORDER-1234",
"create_invoice": true,
"invoice": null,
"created_at": "2026-04-14T10:30:00Z"
}

Authentication

Secure your API requests

Every request must include your API key in the Authorization header:

Authorization: Merchant-Key mk_live_xxxxxxxxxxxxxxxxxxxxx

Or alternatively:

X-Merchant-Key: mk_live_xxxxxxxxxxxxxxxxxxxxx

Environments

Sandbox vs production

mk_test_...Sandbox

Uses sandbox gateway credentials. No real money moves. Use for development and testing.

mk_live_...Production

Uses live gateway credentials. Real money moves. Only use in production.

API Keys

How keys work

API keys are issued per merchant by Centry admins. Each key carries these attributes:

ParameterTypeDescription
environmentstringsandbox or production — determines which credentials are used
country_accountuuid | nullOptional country scope. null = master key (all countries)
can_create_invoicesbooleanRequired to use create_invoice=true on payins
can_create_billsbooleanRequired to use create_bill=true on payouts
can_view_transactionsbooleanRequired to list transactions
can_initiate_payoutsbooleanRequired to create payouts
ip_whiteliststring[]Optional — restrict to specific IPs/CIDRs. Empty = all IPs allowed
single_txn_capdecimal | nullOptional — max amount for any single payin/payout (in the transaction currency)
daily_payout_capdecimal | nullOptional — rolling 24h payout volume limit, scoped per currency
expires_atdatetime | nullOptional expiry

Spend caps

Attach a single_txn_cap and/or daily_payout_cap to limit blast radius if a key is ever leaked. A key with a $5,000 per-transaction cap can't drain your account even if the attacker has full API access.

  • Violations return 403 single_txn_cap_exceeded or 403 daily_payout_cap_exceeded with the cap value in the response body.
  • Caps are in the transaction currency. For master keys spanning countries, the daily cap is tracked independently per currency.
  • In-flight (pending/processing) payouts count against the daily cap so parallel retries can't stack past it.

IP whitelisting

Lock a key to specific source IPs or CIDR ranges. Calls from outside the list are rejected with 403 ip_not_whitelisted and the response body echoes the IP we observed — so integrators can self-diagnose.

  • Entries are bare IPs or CIDR (IPv4 or IPv6): 203.0.113.4, 198.51.100.0/24, 2001:db8::/32.
  • We honor X-Forwarded-For only when the immediate peer is in our trusted-proxy list. Spoofed XFF headers are ignored.
  • Recent rejections are visible in the merchant dashboard (Integration tab → Edit Key Limits) so you can spot misconfigured deploys.
  • Empty whitelist = allow any source. Strongly discouraged for mk_live_ keys.

Provider Accounts

How gateway credentials & fees are managed

A ProviderAccount is the unified record for one payment gateway (Ozow, MTN Mobile Money, Airtel Money, CallPay, M-Pesa). It stores both sandbox and production credentials in a single record, plus the gateway's pass-through fees. Centry resolves the right credentials at request time based on your API key's environment — you never have to switch between separate sandbox/live accounts.

The flow

1Centry admin (or merchant with the right permission) creates a ProviderAccount: provider type, country, name, fee % + fixed, sandbox + production credentials
2A MerchantCountryAccount is auto-created for the country if it doesn't already exist
3The new ProviderAccount is auto-linked to that country account
4When a payin/payout comes in, Centry: looks up the API key's environment → finds the country account → finds the linked ProviderAccount → uses the matching credential set

Fields

ParameterTypeDescription
providerrequiredenummtn_momo, airtel_money, ozow, callpay, daraja_mpesa
namerequiredstringFriendly label, e.g. "Uganda MTN Production"
countrystring (ISO-2)Auto-creates a MerchantCountryAccount if needed
active_environmentenumsandbox or production — picks which credential set is used
credentials_sandboxobjectEncrypted at rest, never exposed in API responses
credentials_productionobjectEncrypted at rest, never exposed in API responses
fee_percentagedecimalGateway pass-through % (e.g. 1.5 for 1.5%)
fee_fixeddecimalGateway pass-through flat fee per transaction
capabilitiesstring[]Auto-populated from provider schema: payin, payout, refund
is_defaultbooleanDefault provider for the country if multiple exist

Country Auto-Linking

You don't have to think about country accounts as a separate concept. When you create a ProviderAccount with country: "UG":

  • • If the merchant already has a Uganda country account → link to it
  • • If not → create one (with the country's default currency) and link
  • • Updating the country on an existing account also re-links automatically

Permissions

Provider account management is gated by org membership permissions:

ParameterTypeDescription
provider_accounts.viewpermissionList and read provider accounts
provider_accounts.createpermissionCreate new provider accounts
provider_accounts.editpermissionUpdate credentials, fees, settings
provider_accounts.deletepermissionDelete a provider account

Owners and admins automatically get all 4 permissions. Custom roles can be granted individual permissions via Organization Members → Edit.

Endpoints

GET
/api/v1/merchants/{id}/provider-accounts

List all provider accounts for the merchant

POST
/api/v1/merchants/{id}/provider-accounts

Create a new provider account (auto-creates country account)

GET
/api/v1/merchants/{id}/provider-accounts/{aid}

Get a single provider account

PATCH
/api/v1/merchants/{id}/provider-accounts/{aid}

Update credentials, fees, or settings

DELETE
/api/v1/merchants/{id}/provider-accounts/{aid}

Delete a provider account

GET
/api/v1/merchants/reference/providers

List supported providers + their credential schemas

Payins

Money in from customers

Create Payin

POST
/v1/payins

Create a collection from a customer

Request body

ParameterTypeDescription
country_coderequiredstringISO 2-letter country code (UG, KE, ZA…)
amountrequireddecimalAmount in currency units
currency_codestringDefaults to country default currency
payment_methodrequiredstringmobile_money · card · bank_transfer · crypto
gatewaystringmtn_momo · airtel_money · ozow · callpay · daraja_mpesa
customer_namestringCustomer name
customer_emailemailCustomer email
customer_phonestringCustomer phone (E.164 format)
referencestringYour own reference for reconciliation
descriptionstringFree-text description
create_invoicebooleanAuto-create invoice in ERP on completion
metadataobjectFree-form JSON, returned in webhooks
webhook_urlurlOverride merchant default webhook for this payin

List Payins

GET
/v1/payins/list?status=completed&limit=50&offset=0

List payins with filters

Get Payin

GET
/v1/payins/{id}

Get a single payin by ID

Complete Payin (Sandbox)

POST
/v1/payins/{id}/complete

Mark a payin as completed (sandbox only — production uses webhooks)

Status lifecycle

pending → processing → completed
↓
failed → cancelled / refunded

Payouts

Money out to recipients

⚠️ Payouts require can_initiate_payouts=true on the API key.

Create Payout

POST
/v1/payouts

Create a disbursement to a recipient

Mobile money payout

{
"country_code": "UG",
"amount": 100000,
"payment_method": "mobile_money",
"gateway": "mtn_momo",
"recipient_name": "John Supplier",
"recipient_phone": "+256700000001",
"reference": "PAY-9876",
"create_bill": true
}

Bank transfer payout

{
"country_code": "ZA",
"amount": 250000,
"currency_code": "ZAR",
"payment_method": "bank_transfer",
"gateway": "ozow",
"recipient_name": "Acme Suppliers Ltd",
"recipient_account_number": "1234567890",
"recipient_bank_name": "Standard Bank",
"recipient_bank_code": "051001",
"create_bill": true
}

Bulk payout

{
"country_code": "UG",
"amount": 0,
"payment_method": "mobile_money",
"gateway": "mtn_momo",
"recipient_name": "Bulk batch",
"is_bulk": true,
"bulk_recipients": [
{ "name": "Alice", "phone": "+256701111111", "amount": 50000 },
{ "name": "Bob", "phone": "+256702222222", "amount": 75000 }
]
}

On payout creation, Centry debits your available balance by amount + fees and holds it in reserved_balance. If your available balance is insufficient, the request returns 400.

Status lifecycle

draft → pending → approved → processing → completed
↓ ↓
rejected failed

Balance

Check your available funds

GET
/v1/balance

Get balance per country + currency

Returns one row per country + currency. If the API key is country-scoped, only that country's balance is returned.

Responsejson
[
{
"country_code": "UG",
"country_name": "Uganda",
"currency_code": "UGX",
"available_balance": "1250000.00",
"pending_balance": "75000.00",
"reserved_balance": "100000.00",
"total_balance": "1325000.00",
"lifetime_payin_volume": "5000000.00",
"lifetime_payout_volume": "2500000.00",
"lifetime_centry_fees": "37500.00",
"lifetime_gateway_fees": "75000.00",
"last_movement_at": "2026-04-14T10:30:00Z"
}
]

Balance states

ParameterTypeDescription
available_balancedecimalReady to use for payouts or withdrawal
pending_balancedecimalPayins not yet cleared by the gateway
reserved_balancedecimalHeld against in-flight payouts
total_balancedecimalavailable + pending (excludes reserved)
lifetime_*decimalRunning totals since onboarding

Fees

Transparent, itemized pricing

Centry breaks out fees into two components stored on every payin and payout:

ParameterTypeDescription
centry_feedecimalCentry's platform fee (default 0.5%, configurable per merchant)
gateway_feedecimalPass-through cost from the resolved ProviderAccount
fee_amountdecimalcentry_fee + gateway_fee
net_amountdecimalPayin: amount − fees (received). Payout: amount + fees (debited)

Where rates come from

  • • Centry fee — Merchant.payin_fee_percentage / payout_fee_percentage, falling back to platform defaults from settings
  • • Gateway fee — ProviderAccount.fee_percentage / fee_fixed for the gateway resolved from the request

Payin fee calculation

centry_fee = amount × Merchant.payin_fee_percentage% + Merchant.payin_fee_fixed
gateway_fee = amount × ProviderAccount.fee_percentage% + ProviderAccount.fee_fixed
net_amount = amount − (centry_fee + gateway_fee) ← what you receive

Payout fee calculation

centry_fee = amount × Merchant.payout_fee_percentage% + Merchant.payout_fee_fixed
gateway_fee = amount × ProviderAccount.fee_percentage% + ProviderAccount.fee_fixed
net_amount = amount + (centry_fee + gateway_fee) ← what you pay in total

ERP Bridge

Auto-sync to your accounting system

Set create_invoice: true on a payin or create_bill: true on a payout. When the payment completes, Centry creates a record in your connected ERP automatically.

ERPPayin → createsPayout → creates
XeroXeroOutgoingInvoices (ACCREC)XeroPayableInvoice (ACCPAY)
QuickBookscoming sooncoming soon
No ERPMerchantInvoice (fallback)MerchantBill (fallback)

In all cases, the invoice/bill is visible in centry-frontend and (for connected ERPs) syncs back to your accounting software.

Idempotency

Safely retry payins and payouts without double-charging

Network blips happen. To make a retry safe, send an Idempotency-Key header on every POST to /v1/payins and /v1/payouts. If the same key reaches us twice, we replay the original response instead of creating a second transaction.

  • The key is an opaque client-generated string — a UUIDv4 is the safe default. 8–255 chars, from A-Z a-z 0-9 _ - : .
  • Keys are scoped per merchant and remembered for 24 hours.
  • Reusing a key with a different request body returns 409 idempotency_key_reused.
  • If the original request is still in-flight, retries get 409 idempotent_request_in_flight — back off and retry shortly.
  • 5xx responses are not cached, so you can safely retry after server errors.
curl -X POST https://api.getcentry.io/v1/payins \
-H "Authorization: Merchant-Key mk_live_xxx" \
-H "Idempotency-Key: 9b2c1d6e-7a3f-4d4e-b1c8-2f6a8c0d4e11" \
-H "Content-Type: application/json" \
-d '{ "country_code": "UG", "amount": 50000, "payment_method": "mobile_money",
"gateway": "mtn", "customer_phone": "+256700000000", "reference": "ORDER-1234" }'

Strongly recommended for production. Without an Idempotency-Key, a network retry can create a duplicate transaction — and for payouts, that means money out twice.

Webhooks

Receive payment events in real time

Centry POSTs to your configured webhook URL whenever payment status changes. Every request is signed with HMAC-SHA256 so you can prove it came from us and wasn't tampered with in transit.

Retry schedule

On non-2xx or timeout we retry at +1m, +5m, +30m, +2h, +24h (6 attempts total). After the budget is exhausted the row stays in failed and you can replay it from the dashboard.

Configure in dashboard

Merchant detail page → Webhooks tab. Edit the URL, generate or rotate the signing secret (shown once), send a webhook.test event, and replay any past delivery.

URL safety

URLs that resolve to private (RFC1918), loopback, link-local, or cloud-metadata IPs (e.g. 169.254.169.254) are rejected at write time and re-checked at send time. Use https in production.

Reply 2xx fast

Centry expects a 2xx within ~10s. Do heavy work async — push the event onto your own queue and return 200 immediately, or you'll hit retries.

Envelope

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"event": "payin.completed",
"created": 1713090900,
"livemode": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_id": "b2d7...",
"status": "completed",
"amount": "50000.00",
"currency": "UGX",
"fee_amount": "750.00",
"net_amount": "49250.00",
"gateway": "mtn",
"payment_method": "mobile_money",
"reference": "ORDER-1234",
"customer_phone": "+256700000000",
"created_at": "2026-04-14T10:35:00+00:00"
}
}

Event types

ParameterTypeDescription
payin.pendingeventCreated, awaiting gateway
payin.completedeventPayment confirmed, funds available
payin.failedeventGateway rejected or timed out
payin.refundedeventRefund processed
payout.submittedeventCreated and funds reserved
payout.completedeventSuccessfully disbursed
payin.createdeventNew payin row recorded (alias of payin.pending)
payout.failedeventDisbursement failed — funds returned to available
webhook.testeventFired by the dashboard "Send test" button — safe to ignore in production handlers

Signature verification

Every request carries a Centry-Signature header combining a Unix timestamp and an HMAC-SHA256 signature:

Centry-Signature: t=1713090900,v1=5257a8695...
  • t — Unix timestamp (seconds) when we signed the payload.
  • v1 — hex-encoded HMAC-SHA256 of `${t}.${raw_body}` using your merchant webhook_secret.

Three checks to run, in order: parse the header, reject if t is more than 5 minutes off current time (replay guard), then constant-time compare your recomputed HMAC to the v1 value. Use the raw request body bytes — don't re-encode the JSON, or the signature will never match.

const crypto = require('crypto');
function verifyCentryWebhook(rawBody, headerValue, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
headerValue.split(',').map(p => p.split('=').map(s => s.trim()))
);
const t = parseInt(parts.t, 10);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1 || '', 'hex'),
);
}
// Express: use express.raw({ type: 'application/json' }) so req.body stays
// as a Buffer — JSON-parsing before verification will break the signature.
app.post('/webhooks/centry', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyCentryWebhook(
req.body.toString('utf8'),
req.get('Centry-Signature'),
process.env.CENTRY_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.status(200).send();
});
Always verify before acting. An unverified webhook could be forged — anyone who knows your URL can POST fake payin.completed events. Without verification you could ship orders that were never paid for.

Error Codes

HTTP status codes and messages

ParameterTypeDescription
200okRequest succeeded
201createdResource created
400bad_requestValidation error or insufficient balance
401unauthorizedInvalid or expired API key
403forbiddenMissing permission, IP not whitelisted, or country mismatch
404not_foundResource not found
429rate_limitedRate limit exceeded
500server_errorUnexpected server error

Error response format

{
"error": "Insufficient balance: available 10000 < required 50750"
}

Rate Limits

API usage limits

  • • 60 requests/minute per API key prefix
  • • Rate limit information returned in response headers: X-RateLimit-Limit, X-RateLimit-Remaining
  • • Exceeding the limit returns 429 Too Many Requests
  • • Contact support for higher limits on production workloads

OpenAPI / Swagger

Interactive API reference with Try it out

The public API schema is published as OpenAPI 3.0 and rendered in Swagger UI + ReDoc. Both are served unauthenticated, so you can browse without a dashboard session. Point your code generator (Postman, Insomnia, openapi-generator, orval, …) at the raw schema URL.

Swagger UI

Interactive explorer with Try it out.

/api/docs/

ReDoc

Clean three-column reference. No Try-it-out; better for reading.

/api/redoc/

Raw schema

OpenAPI 3.0 YAML — feed it to Postman, Insomnia, or your code generator.

/api/schema/
What's in the public schema: /v1/* (merchant API, mk_test_/mk_live_ auth), /api/v1/checkout/* (hosted checkout, cen_xxx), and /api/v1/merchants/* (merchant management, dashboard JWT). Internal admin surfaces are deliberately out of scope — they change often and would make the public docs noisy.
Try it out in Swagger: click Authorize (top right). Four entries — pick the one that matches the endpoint:
  • CentryMerchantKey — paste Merchant-Key mk_test_… for payins / payouts / balance. Use sandbox keys here, not live.
  • CentryApiKey — paste Api-Key cen_xxx_… for hosted checkout endpoints.
  • CentrySession / CentryJWT — dashboard-only, relevant if you're calling the merchant management API from a browser session.
Never paste a live key into Swagger unless you're on a trusted machine — the dialog stores it in-memory and your browser extensions may see it. Sandbox first.

Going-Live Checklist

Work through these in order before flipping mk_test_ for mk_live_

We've seen most launch incidents trace back to one of these items being skipped. Tick each one in your dashboard before pointing real customer traffic at us.

  1. 1

    Confirm sandbox flow end-to-end

    Create a payin and payout against mk_test_, watch them complete via the gateway sandbox. Use the Python demo or pycentry SDK if you want a working starting point.

  2. 2

    Set webhook_url + generate signing secret

    Merchant detail → Webhooks tab. URL must be https; private/loopback/metadata IPs are rejected. Click "Generate" to mint a webhook_secret — copy it now, it is shown once.

  3. 3

    Verify signatures + send a test event

    Wire verify_centry_signature into your receiver. From the dashboard, click "Send test" — a webhook.test event arrives. Confirm your handler validates and 2xxs within 10s.

  4. 4

    Replay one event, then trigger a real one

    Use the Replay button in the Webhooks tab to verify your idempotent handling. Then run a real sandbox payin and verify the payin.completed event lands.

  5. 5

    Add IP whitelist to the production key

    Integration tab → Edit Key Limits. Paste the CIDR of your egress IPs. Empty whitelist on mk_live_ is dangerous and we will warn you in the UI.

  6. 6

    Set per-key spend caps

    Same dialog: single_txn_cap (largest single payin/payout you ever expect) and daily_payout_cap (rolling 24h budget). A leaked key with caps is a contained incident.

  7. 7

    Configure payout approval threshold (recommended)

    For your organization, set merchant_payout_approval_threshold per currency. Payouts above the threshold flip to pending and need a human click — small operational cost, huge fraud win.

  8. 8

    Send Idempotency-Key on every payin and payout

    UUIDv4 per logical request. Without it, a network retry can charge or pay out twice. We also send 409 idempotency_key_reused if the same key arrives with a different body — surface this to ops, do not auto-retry.

  9. 9

    Rotate the sandbox key, mint a fresh production key

    Never re-use sandbox keys in live. Generate a new mk_live_ explicitly for production, with the right country scope, permission set, IP whitelist, and caps.

  10. 10

    Run reconciliation against your accounting system

    For your first week of live traffic, reconcile our payin/payout records against your books daily. Confirm fees and net amounts match. Stuck transactions are auto-failed after 30m and the merchant balance released.

Security Best Practices

  • Use separate keys per environment. Never mix sandbox and production credentials.
  • Scope keys to a country. If an integrator only handles Uganda, their key shouldn't be able to transact in South Africa.
  • Always verify webhook signatures. Reject webhooks without a valid signature header.
  • Send an Idempotency-Key on every payin and payout. Without it, a network retry can charge or pay out twice. See Idempotency.
  • Attach spend caps to production keys. A single_txn_cap and daily_payout_cap turn a stolen key from a disaster into a contained incident.
  • Rotate keys periodically. Revoke any key that may have been exposed.