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
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.
Plug into your ERP
Connect Xero, QuickBooks, or SAP in minutes. Invoices, bills, contacts, and chart of accounts stay in sync both ways.
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.
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.
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.
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 requestsresp = 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'
from centry import Clientclient = 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
{"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_...SandboxUses sandbox gateway credentials. No real money moves. Use for development and testing.
mk_live_...ProductionUses 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:
| Parameter | Type | Description |
|---|---|---|
environment | string | sandbox or production — determines which credentials are used |
country_account | uuid | null | Optional country scope. null = master key (all countries) |
can_create_invoices | boolean | Required to use create_invoice=true on payins |
can_create_bills | boolean | Required to use create_bill=true on payouts |
can_view_transactions | boolean | Required to list transactions |
can_initiate_payouts | boolean | Required to create payouts |
ip_whitelist | string[] | Optional — restrict to specific IPs/CIDRs. Empty = all IPs allowed |
single_txn_cap | decimal | null | Optional — max amount for any single payin/payout (in the transaction currency) |
daily_payout_cap | decimal | null | Optional — rolling 24h payout volume limit, scoped per currency |
expires_at | datetime | null | Optional 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_exceededor403 daily_payout_cap_exceededwith 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-Foronly 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
Fields
| Parameter | Type | Description |
|---|---|---|
providerrequired | enum | mtn_momo, airtel_money, ozow, callpay, daraja_mpesa |
namerequired | string | Friendly label, e.g. "Uganda MTN Production" |
country | string (ISO-2) | Auto-creates a MerchantCountryAccount if needed |
active_environment | enum | sandbox or production — picks which credential set is used |
credentials_sandbox | object | Encrypted at rest, never exposed in API responses |
credentials_production | object | Encrypted at rest, never exposed in API responses |
fee_percentage | decimal | Gateway pass-through % (e.g. 1.5 for 1.5%) |
fee_fixed | decimal | Gateway pass-through flat fee per transaction |
capabilities | string[] | Auto-populated from provider schema: payin, payout, refund |
is_default | boolean | Default 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:
| Parameter | Type | Description |
|---|---|---|
provider_accounts.view | permission | List and read provider accounts |
provider_accounts.create | permission | Create new provider accounts |
provider_accounts.edit | permission | Update credentials, fees, settings |
provider_accounts.delete | permission | Delete a provider account |
Owners and admins automatically get all 4 permissions. Custom roles can be granted individual permissions via Organization Members → Edit.
Endpoints
/api/v1/merchants/{id}/provider-accountsList all provider accounts for the merchant
/api/v1/merchants/{id}/provider-accountsCreate a new provider account (auto-creates country account)
/api/v1/merchants/{id}/provider-accounts/{aid}Get a single provider account
/api/v1/merchants/{id}/provider-accounts/{aid}Update credentials, fees, or settings
/api/v1/merchants/{id}/provider-accounts/{aid}Delete a provider account
/api/v1/merchants/reference/providersList supported providers + their credential schemas
Payins
Money in from customers
Create Payin
/v1/payinsCreate a collection from a customer
Request body
| Parameter | Type | Description |
|---|---|---|
country_coderequired | string | ISO 2-letter country code (UG, KE, ZA…) |
amountrequired | decimal | Amount in currency units |
currency_code | string | Defaults to country default currency |
payment_methodrequired | string | mobile_money · card · bank_transfer · crypto |
gateway | string | mtn_momo · airtel_money · ozow · callpay · daraja_mpesa |
customer_name | string | Customer name |
customer_email | Customer email | |
customer_phone | string | Customer phone (E.164 format) |
reference | string | Your own reference for reconciliation |
description | string | Free-text description |
create_invoice | boolean | Auto-create invoice in ERP on completion |
metadata | object | Free-form JSON, returned in webhooks |
webhook_url | url | Override merchant default webhook for this payin |
List Payins
/v1/payins/list?status=completed&limit=50&offset=0List payins with filters
Get Payin
/v1/payins/{id}Get a single payin by ID
Complete Payin (Sandbox)
/v1/payins/{id}/completeMark 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
/v1/payoutsCreate 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
/v1/balanceGet balance per country + currency
Returns one row per country + currency. If the API key is country-scoped, only that country's balance is returned.
[{"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
| Parameter | Type | Description |
|---|---|---|
available_balance | decimal | Ready to use for payouts or withdrawal |
pending_balance | decimal | Payins not yet cleared by the gateway |
reserved_balance | decimal | Held against in-flight payouts |
total_balance | decimal | available + pending (excludes reserved) |
lifetime_* | decimal | Running totals since onboarding |
Fees
Transparent, itemized pricing
Centry breaks out fees into two components stored on every payin and payout:
| Parameter | Type | Description |
|---|---|---|
centry_fee | decimal | Centry's platform fee (default 0.5%, configurable per merchant) |
gateway_fee | decimal | Pass-through cost from the resolved ProviderAccount |
fee_amount | decimal | centry_fee + gateway_fee |
net_amount | decimal | Payin: 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_fixedfor the gateway resolved from the request
Payin fee calculation
centry_fee = amount × Merchant.payin_fee_percentage% + Merchant.payin_fee_fixedgateway_fee = amount × ProviderAccount.fee_percentage% + ProviderAccount.fee_fixednet_amount = amount − (centry_fee + gateway_fee) ← what you receive
Payout fee calculation
centry_fee = amount × Merchant.payout_fee_percentage% + Merchant.payout_fee_fixedgateway_fee = amount × ProviderAccount.fee_percentage% + ProviderAccount.fee_fixednet_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.
| ERP | Payin → creates | Payout → creates |
|---|---|---|
| Xero | XeroOutgoingInvoices (ACCREC) | XeroPayableInvoice (ACCPAY) |
| QuickBooks | coming soon | coming soon |
| No ERP | MerchantInvoice (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
| Parameter | Type | Description |
|---|---|---|
payin.pending | event | Created, awaiting gateway |
payin.completed | event | Payment confirmed, funds available |
payin.failed | event | Gateway rejected or timed out |
payin.refunded | event | Refund processed |
payout.submitted | event | Created and funds reserved |
payout.completed | event | Successfully disbursed |
payin.created | event | New payin row recorded (alias of payin.pending) |
payout.failed | event | Disbursement failed — funds returned to available |
webhook.test | event | Fired 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 merchantwebhook_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();});
payin.completed events. Without verification you could ship orders that were never paid for.Error Codes
HTTP status codes and messages
| Parameter | Type | Description |
|---|---|---|
200 | ok | Request succeeded |
201 | created | Resource created |
400 | bad_request | Validation error or insufficient balance |
401 | unauthorized | Invalid or expired API key |
403 | forbidden | Missing permission, IP not whitelisted, or country mismatch |
404 | not_found | Resource not found |
429 | rate_limited | Rate limit exceeded |
500 | server_error | Unexpected 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//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.- 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.
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
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
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
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
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
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
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
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
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
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
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_capanddaily_payout_capturn a stolen key from a disaster into a contained incident. - Rotate keys periodically. Revoke any key that may have been exposed.