Integrate with Elro Pay
Elro Pay is a shared Paystack-backed payments microservice. Your backend registers once as a client app, gets a credential, and every payment, payout, and settlement lookup it makes is scoped to that credential. You never see another app's merchants or transactions, and no one sees yours.
This reference covers everything you'll call directly: collecting a payment, sending a payout, checking settlement data, and receiving webhooks. If you're integrating for the first time, read the Quickstart next: it walks through the whole flow end to end before you touch any individual endpoint.
Quickstart
The shape of a typical integration, start to finish. Each step links to its full reference further down this page.
-
Get onboarded and receive your API key
There's no self-serve signup. Elro Pay ops creates a merchant record for your business, resolves its payout account, and registers a dedicated client app for it in one call, see Get your API key. The response includes your key exactly once. Store it server-side; it's the only credential you need for everything below.
-
(Optional) Confirm an account before you act on it
Before onboarding a payout recipient, or just to reassure a customer, resolve a bank/mobile-money account to its real holder name with Resolve an account name. Look up valid bank/network codes first with List banks & mobile money, which is public and needs no auth.
-
Collect a payment
Call Initiate a payment with your merchant id, an amount, and your customer's email. Redirect the customer's browser to the returned
authorization_url: Paystack's own hosted checkout handles the rest, you don't build any payment UI. -
Find out what happened
Two ways, not mutually exclusive: register a webhook and Elro Pay pushes a signed event to your backend the moment the charge resolves, or poll Get payment status yourself. Most integrations do both: webhook for speed, occasional polling as a safety net.
-
Pay money back out, when you need to
Register a withdrawal destination once with Register a recipient, then call Send a payout whenever you need to move money to it. Every payout is checked against your app's own running balance first, see Key concepts for how that's funded.
-
Reconcile
Once Paystack settles a collection to a merchant's bank, confirm it actually landed, and net of what fees, with List settlements. This is a live lookup against Paystack, not a local copy, so treat it as a convenience check today rather than your system of record.
Key concepts
Four things this API talks about constantly. Getting these straight up front makes everything else click.
Client app ClientApp
The caller: your backend. Identified by your X-Elro-App-Key. Every resource you create or read is scoped to this identity; it's the tenant-isolation boundary the whole API is built around.
Merchant Merchant
The payee: who a collection settles to. Belongs to exactly one client app. You reference a merchant's id when initiating a payment; you never see or touch another app's merchants.
Recipient TransferRecipient
A registered payout destination: a bank account or mobile money number you've sent money to before, or intend to. Distinct from a merchant: recipients are yours, not tied to who paid you.
Ledger AppLedger
Your app's running payout balance, credited automatically from the platform's share of your merchants' successful collections. A payout is checked against this balance before it ever reaches Paystack, so you can't pay out more than your app has actually earned.
Public, no auth required. Returns every Ghana bank and mobile money network Paystack supports, with the exact code value Resolve an account name and payout recipient registration expect as bank_code.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| type | string | optional | all (default), bank, or mobile_money. |
| force_refresh | boolean | optional | Bypass the 24h cache and re-fetch from Paystack. Rarely needed, since bank/network lists change infrequently. |
Response fields
| Field | Type | Description |
|---|---|---|
| data.banks[] | array | Traditional banks, present unless type=mobile_money. |
| data.mobile_money[] | array | Mobile money networks, present unless type=bank. |
| .code | string | Pass this as bank_code elsewhere. |
| .name | string | Display name. |
| .active | boolean | Whether Paystack currently accepts this code. |
type value returns {"success": false, "message": "..."}, not the detail/field-keyed shapes described in Errors. All three /banks/* endpoints share this shape; check for success: false here specifically.
curl "https://payment-server.wasktechnologies.com/api/v1/banks/ghana?type=mobile_money"
{
"success": true,
"message": "Ghana banks retrieved successfully. 0 banks, 3 mobile money providers.",
"data": {
"mobile_money": [
{ "code": "MTN", "name": "MTN", "active": true },
{ "code": "VOD", "name": "Vodafone", "active": true },
{ "code": "ATL", "name": "AirtelTigo", "active": true }
]
},
"country": "Ghana"
}
type 400
{
"success": false,
"message": "Invalid type. Use: all, bank, or mobile_money"
}
Public, no auth required. The same bank/mobile-money lookup as List Ghana banks & mobile money, but across every country Paystack supports, not just Ghana. Use this if you ever need a non-Ghana bank_code; every other endpoint on this page (payments, payouts) only operates on Ghana accounts today.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| country | string | optional | Filter to one country, e.g. ghana. Omit for every supported country. |
| type | string | optional | all (default), bank, or mobile_money. |
| force_refresh | boolean | optional | Bypass the cache and re-fetch from Paystack. |
Response fields
| Field | Type | Description |
|---|---|---|
| data.banks[] | array | Traditional banks, present unless type=mobile_money. |
| data.mobile_money[] | array | Mobile money networks, present unless type=bank. |
| countries[] | array | Every country present in this response, e.g. ["nigeria","ghana",...]. |
{"success": false, "message": "..."} on a bad type, or a 404 with the same shape if country matches nothing.
curl "https://payment-server.wasktechnologies.com/api/v1/banks/all?country=ghana&type=mobile_money"
{
"success": true,
"message": "All banks retrieved successfully",
"data": {
"mobile_money": [
{ "code": "MTN", "name": "MTN", "active": true }
]
},
"countries": ["nigeria", "ghana", "kenya", "south africa"]
}
Confirm a payout/customer account is real and see the name it belongs to, before you act on it. One endpoint handles both mobile money and bank accounts, since Paystack resolves both the same way. What differs is which code you pass as bank_code.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| account_number | string | required | The bank account number, or the mobile money number for mobile money. |
| bank_code | string | required | The bank's Paystack code, or the network code (e.g. MTN) for mobile money. |
Response fields
| Field | Type | Description |
|---|---|---|
| data.account_number | string | Echoed back. |
| data.account_name | string | The real, registered name on the account. |
| data.bank_id | integer | Paystack's internal numeric id for the bank/network. |
GET /banks/ghana?type=bank or ?type=mobile_money (public, no auth needed) lists every valid bank_code Paystack accepts for Ghana, banks and mobile money networks both.
This hits Paystack with our credentials and returns real account-holder PII (a real name tied to a real number). Handle the response accordingly, don't log it anywhere persistent. Not scoped to a merchant/app the way payments are: any registered client app can resolve any account number.
Errors here use the same {"success": false, "message": "..."} shape as the bank-list endpoints, not the detail/field-keyed shapes in Errors, e.g. a missing bank_code returns {"success": false, "message": "Both account_number and bank_code are required."} at 400.
curl "https://payment-server.wasktechnologies.com/api/v1/banks/resolve?account_number=0551234567&bank_code=MTN" \ -H "X-Elro-App-Key: epk_live_..."
{
"success": true,
"message": "Account resolved successfully",
"data": {
"account_number": "0551234567",
"account_name": "Kwame Mensah",
"bank_id": 28
}
}
There's no self-serve signup. Elro Pay ops (staff, logged into Django Admin) create one sub-account per merchant/business being onboarded. That single call is also where your API key is born:
- Ops resolves the merchant's bank/mobile-money account and creates its Paystack sub-account.
- Elro Pay provisions a brand-new client app dedicated to that merchant, which is what isolates it from every other merchant on the platform.
- The response includes
api_key, shown exactly this once. Ops copies it out and sends it to you (the integrating developer) over a secure channel: Slack DM, a password manager share, not email/plaintext.
The merchant id (a uuid) passed into this call already needs to exist as an Elro Pay Merchant record, created by ops beforehand. If you're the frontend developer reading this: you won't call this endpoint yourself. This is here so you understand where your key comes from and why it's scoped the way it is (one key per merchant, not one shared key for the whole platform).
{
"id": "9c1e2a4d-...-b7f0",
"merchant": "b6a9b3d0-...-9e10",
"name": "Ama Boateng",
"bank_name": "GCB Bank",
"percentage_charge": "5.00",
"is_verified": true,
// the two fields that matter for integration:
// api_key is never shown again after this response
"client_app_code": "ama-boateng",
"api_key": "epk_live_3f9a1c2d5e6b7a80k7Hs9v..."
}
Authentication
Every request carries your credential in a dedicated header, not Authorization, so it never collides with any bearer-token scheme.
Your key comes from the sub-account creation call above, shown exactly one time. It can't be retrieved again after that response. If it leaks, ask ops to revoke it; revoking your key never affects any other merchant's key.
Keep it server-side only. It authenticates your backend, not your end users. Never ship it to a browser or mobile app.
X-Elro-App-Key: epk_live_3f9a1c2d5e6b7a80k7Hs9vQ2pXyZAbCdEfGhIjKlMnOpQrStUvWx
Base URL
| Environment | URL |
|---|---|
| Production | https://payment-server.wasktechnologies.com/api/v1 |
| Local / dev | http://localhost:8000/api/v1 |
All paths below are relative to this base.
Errors
Most errors are a JSON object with a detail string, plus the status codes below. Field-level validation errors (a missing or malformed body field) instead come back keyed by field name, e.g. {"amount": ["Ensure this value is greater than or equal to 1.00."]}: check for a detail key first, and fall back to inspecting the field names if it's absent. A few status codes are deliberate: a merchant id that belongs to another app returns the same 404 as one that doesn't exist at all: Elro Pay never confirms what it can't show you.
/banks/ghana, /banks/all, and /banks/resolve use a third shape, {"success": false, "message": "..."}, not detail or field-keyed errors. See List Ghana banks & mobile money for a live example.
| Status | Meaning |
|---|---|
| 201 | Resource created (payment initiated, payout sent, recipient registered). |
| 200 | Request succeeded. |
| 400 | Validation failed, or a business-rule check failed (e.g. insufficient payout balance, no payout account configured). |
| 403 | Missing, malformed, revoked, or invalid credential. |
| 404 | Not found, including a valid id that belongs to a different app. |
| 429 | Rate limit exceeded. See Rate limits. |
| 502 | Paystack itself is unreachable or returned an error. |
Starts a Paystack charge against one of your merchants. Redirect your customer to the returned authorization_url to complete payment.
Body parameters
| Field | Type | Description | |
|---|---|---|---|
| merchant | uuid | required | Id of the merchant being paid. Must belong to your app. |
| amount | string | required | Charge amount in GHS, e.g. "150.00". Not pesewas. Minimum ₵0.01, no configured maximum. |
| customer_email | string | required | Your customer's email, passed straight to Paystack. |
| user_reference | string | optional | Your own internal user/order id. Stored verbatim, echoed back on status checks and callbacks. |
| metadata | object | optional | Freeform, forwarded to Paystack's own transaction metadata. |
Response fields
| Field | Type | Description |
|---|---|---|
| success | boolean | Always true on a 201. |
| authorization_url | string | Redirect your customer here to complete checkout. |
| reference | string | This payment's id. Use it with Get payment status. |
| access_code | string | Paystack's own access code, useful only if you're embedding their inline checkout widget instead of redirecting. |
curl -X POST https://payment-server.wasktechnologies.com/api/v1/apps/payments/initiate/ \ -H "X-Elro-App-Key: epk_live_..." \ -H "Content-Type: application/json" \ -d '{ "merchant": "b6a9b3d0-1234-4a1b-9c2e-8f6a2d5c9e10", "amount": "150.00", "customer_email": "tenant@example.com", "user_reference": "order-4821", "metadata": {"lease_id": "L-2026-0192"} }'
{
"success": true,
"authorization_url": "https://checkout.paystack.com/abc123xyz",
"reference": "PAY-9F3C7A21B8D4",
"access_code": "0peioxfhpn"
}
Read-only lookup of a payment by its reference. No live Paystack call, just what Elro Pay has recorded so far.
A reference that belongs to another app returns 404, same as one that doesn't exist.
Response fields
| Field | Type | Description |
|---|---|---|
| reference | string | This payment's id. |
| user_reference | string | Whatever you supplied at initiate time. |
| amount | string | GHS. |
| currency | string | Always "GHS" today. |
| status | string | See Payment statuses. |
| customer_email | string | As supplied at initiate time. |
| authorization_url | string | The same checkout URL from initiate, for reference. |
| paid_at | string | null | ISO 8601 timestamp once status is SUCCESS, else null. |
| created_at | string | ISO 8601 timestamp. |
curl https://payment-server.wasktechnologies.com/api/v1/apps/payments/status/PAY-9F3C7A21B8D4/ \
-H "X-Elro-App-Key: epk_live_..."
{
"reference": "PAY-9F3C7A21B8D4",
"user_reference": "order-4821",
"amount": "150.00",
"currency": "GHS",
"status": "PENDING",
"customer_email": "tenant@example.com",
"authorization_url": "https://checkout.paystack.com/abc123xyz",
"paid_at": null,
"created_at": "2026-07-29T09:12:03.745Z"
}
Paginated history, always scoped to your app regardless of what you pass. Filters can only narrow the results, never widen them past your own data.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| status | string | optional | One of the payment statuses, see reference below. |
| user_reference | string | optional | Filter to a single order/user id you supplied at initiate time. |
| page | integer | optional | Default 1. |
| page_size | integer | optional | Default 25, max 100. |
Response fields
| Field | Type | Description |
|---|---|---|
| count | integer | Total matching results across all pages. |
| next | string | null | URL of the next page, or null on the last page. |
| previous | string | null | URL of the previous page, or null on the first. |
| results[] | array | Same shape as Get payment status's response. |
curl "https://payment-server.wasktechnologies.com/api/v1/apps/payments/?status=SUCCESS&user_reference=order-4821" \ -H "X-Elro-App-Key: epk_live_..."
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"reference": "PAY-9F3C7A21B8D4",
"user_reference": "order-4821",
"amount": "150.00",
"currency": "GHS",
"status": "SUCCESS",
"customer_email": "tenant@example.com",
"authorization_url": "https://checkout.paystack.com/abc123xyz",
"paid_at": "2026-07-29T09:14:51.002Z",
"created_at": "2026-07-29T09:12:03.745Z"
}
]
}
Register a withdrawal destination for your app: a mobile money number or a bank account. Do this once per destination, then reuse the returned id on every payout to it.
Body parameters
| Field | Type | Description | |
|---|---|---|---|
| account_number | string | required | The bank account number, or the mobile money number. |
| bank_code | string | required | The bank's Paystack code, or the network code (e.g. MTN). See List banks & mobile money. |
Response fields
| Field | Type | Description |
|---|---|---|
| id | uuid | Reference this in Send a payout. |
| type | string | mobile_money or bank, determined automatically. |
| name | string | Real account holder name, from Paystack's own resolution. |
| account_number | string | Echoed back. |
| bank_name | string | Display name for the bank/network. |
| recipient_code | string | Paystack's own recipient code, for your records. |
| is_active | boolean | false if a later re-resolve found the name no longer matches. |
| created_at | string | ISO 8601 timestamp. |
type field on input. Whether this is mobile money or a bank account is worked out automatically from which list bank_code appears in, so there's nothing to get wrong here.
Idempotent: calling this again with the same account_number/bank_code returns the existing recipient instead of registering a duplicate, unless the account holder's name has changed since it was first registered. In that case you'll get a 400 and need to register it again explicitly, on purpose: a changed name on a payout destination is treated as a real risk signal, not something to silently keep trusting.
curl -X POST https://payment-server.wasktechnologies.com/api/v1/apps/payouts/recipients/create/ \ -H "X-Elro-App-Key: epk_live_..." \ -H "Content-Type: application/json" \ -d '{ "account_number": "0551234567", "bank_code": "MTN" }'
{
"id": "7a2e1c9d-...-4f3b",
"type": "mobile_money",
"name": "Kwame Mensah",
"account_number": "0551234567",
"bank_name": "MTN",
"recipient_code": "RCP_u2tnoyjjvh95pzm",
"is_active": true,
"created_at": "2026-09-15T10:02:11.000Z"
}
List your app's own registered payout recipients. No parameters.
Response fields
| Field | Type | Description |
|---|---|---|
| count / next / previous | – | Standard pagination, same shape as List payments. |
| results[] | array | Same shape as Register a recipient's response. |
curl https://payment-server.wasktechnologies.com/api/v1/apps/payouts/recipients/ \
-H "X-Elro-App-Key: epk_live_..."
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": "7a2e1c9d-...-4f3b",
"type": "mobile_money",
"name": "Kwame Mensah",
"account_number": "0551234567",
"bank_name": "MTN",
"recipient_code": "RCP_u2tnoyjjvh95pzm",
"is_active": true,
"created_at": "2026-09-15T10:02:11.000Z"
}
]
}
Send money to one of your registered recipients. Unlike initiating a payment, this moves real money immediately: there's no separate "customer completes checkout" step.
Body parameters
| Field | Type | Description | |
|---|---|---|---|
| recipient | uuid | required | Id of a recipient from Register a recipient. Must belong to your app. |
| amount | string | required | Amount in GHS, e.g. "50.00". Not pesewas. Minimum ₵1.00, maximum ₵500.00 per payout today (ops-configurable; ask if your integration needs a higher single-payout cap). |
| reason | string | optional | Shown on the recipient's bank/mobile-money statement. |
| user_reference | string | optional | Your own idempotency key. Calling this twice with the same value returns the original payout instead of sending money twice. |
Response fields
| Field | Type | Description |
|---|---|---|
| success | boolean | Always true on a 201. requires_otp counts as success, the payout record is real. |
| status | string | pending or requires_otp. Poll status for the final outcome. |
| reference | string | This payout's id. |
| transfer_code | string | Paystack's own transfer code, present when status is pending. |
| message | string | Present when status is requires_otp, human-readable context. |
status: "requires_otp" is not an error. Paystack sometimes requires a one-time code (texted to Elro Pay's own account owner, not you or your customer) before a transfer is released, as a fraud-prevention step on Paystack's side. You still get a 201 back: the payout was created and is real, it's just not finished yet. Elro Pay ops confirms it; you don't do anything. There's no fixed timeline since it depends on a human seeing the prompt, so poll status the same way you would for a PENDING payout, and show your own end user something like "processing" rather than implying it's stuck.
Responses
| Status | Meaning |
|---|---|
| 201 | Payout created, status pending or requires_otp. |
| 400 | Validation failed, or your app doesn't have enough balance for this payout. |
| 404 | The recipient id doesn't exist, isn't yours, or was deactivated (see Register a recipient's name-mismatch case). |
| 502 | Paystack rejected the transfer. |
There's no endpoint to check your available balance ahead of time today; the only signal is this 400 if a payout you attempt exceeds it. Your app's ledger is credited automatically from your merchants' successful collections, see Key concepts.
curl -X POST https://payment-server.wasktechnologies.com/api/v1/apps/payouts/initiate/ \ -H "X-Elro-App-Key: epk_live_..." \ -H "Content-Type: application/json" \ -d '{ "recipient": "7a2e1c9d-1234-4a1b-9c2e-8f6a2d5c9e10", "amount": "50.00", "reason": "Wallet cash out", "user_reference": "cashout-9931" }'
{
"success": true,
"status": "pending",
"reference": "PYT-9F3C7A21B8D4",
"transfer_code": "TRF_1ptvuv321ahaa7q"
}
{
"success": true,
"status": "requires_otp",
"reference": "PYT-9F3C7A21B8D4",
"message": "Transfer created, pending manual confirmation."
}
Read-only lookup of a payout by its reference, no live Paystack call, just what Elro Pay has recorded so far.
A reference that belongs to another app returns 404, same as one that doesn't exist.
Response fields
| Field | Type | Description |
|---|---|---|
| reference | string | This payout's id. |
| user_reference | string | Whatever you supplied at initiate time. |
| amount | string | GHS. |
| currency | string | Always "GHS" today. |
| status | string | See the statuses table below. |
| reason | string | As supplied at initiate time. |
| recipient | object | Nested recipient summary, same shape as Register a recipient's response. |
| completed_at | string | null | ISO 8601 timestamp once status is SUCCESS, else null. |
| created_at | string | ISO 8601 timestamp. |
Payout statuses
| Status | Meaning |
|---|---|
| PENDING | Submitted to Paystack, not yet confirmed. |
| SUCCESS | Paystack confirmed the money moved. |
| FAILED | The transfer was declined or errored. |
| REVERSED | Paystack sent the money back to the platform balance after it couldn't be delivered. |
| REQUIRES_OTP | Held for a one-time confirmation on Elro Pay's side. Not an error, see Send a payout. |
curl https://payment-server.wasktechnologies.com/api/v1/apps/payouts/status/PYT-9F3C7A21B8D4/ \
-H "X-Elro-App-Key: epk_live_..."
{
"reference": "PYT-9F3C7A21B8D4",
"user_reference": "cashout-9931",
"amount": "50.00",
"currency": "GHS",
"status": "SUCCESS",
"reason": "Wallet cash out",
"recipient": {
"id": "7a2e1c9d-...-4f3b",
"type": "mobile_money",
"name": "Kwame Mensah",
"account_number": "0551234567",
"bank_name": "MTN"
},
"completed_at": "2026-09-15T10:05:41.002Z",
"created_at": "2026-09-15T10:05:12.745Z"
}
Paginated payout history, always scoped to your app regardless of what you pass. Filters can only narrow the results, never widen them past your own data.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| status | string | optional | One of the payout statuses above. |
| user_reference | string | optional | Filter to a single payout id you supplied at initiate time. |
| page | integer | optional | Default 1. |
| page_size | integer | optional | Default 25, max 100. |
curl "https://payment-server.wasktechnologies.com/api/v1/apps/payouts/?status=SUCCESS" \ -H "X-Elro-App-Key: epk_live_..."
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"reference": "PYT-9F3C7A21B8D4",
"user_reference": "cashout-9931",
"amount": "50.00",
"currency": "GHS",
"status": "SUCCESS",
"reason": "Wallet cash out",
"recipient": { "id": "7a2e1c9d-...-4f3b", "type": "mobile_money", "name": "Kwame Mensah", "account_number": "0551234567", "bank_name": "MTN" },
"completed_at": "2026-09-15T10:05:41.002Z",
"created_at": "2026-09-15T10:05:12.745Z"
}
]
}
Reconciliation data: not just "did the customer pay" but "did the money actually reach the merchant's bank, when, and net of what fees." A collection settles to the merchant automatically via Paystack's subaccount split, entirely outside anything Elro Pay writes locally, so this is a live lookup against Paystack's own Settlement API, scoped to one merchant at a time, not a local copy.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| merchant | uuid | required | Id of the merchant to list settlements for. Must belong to your app. |
| status | string | optional | One of success, processing, pending, failed. |
| page | integer | optional | Default 1. |
Response fields
| Field | Type | Description |
|---|---|---|
| results[].id | string | Paystack's settlement id. Use with List settlement transactions. |
| results[].status | string | success, processing, pending, or failed. |
| results[].total_amount | string | GHS, gross, before fees. |
| results[].total_fees | string | GHS, what Paystack deducted. |
| results[].effective_amount | string | GHS, what actually landed in the merchant's bank: total_amount − total_fees. |
| results[].total_transactions | integer | Number of charges rolled into this settlement. |
| results[].settlement_date | string | Date the settlement was (or will be) paid out. |
| meta | object | Paystack's own pagination shape, see the callout below. |
meta object is Paystack's native pagination (page/perPage/pageCount/total), not the count/next/previous shape the rest of this API uses for local data. If you write one generic pagination handler for Payments/Payouts, this endpoint needs its own case.
No local copy, no cache: if Paystack is unreachable this returns 502, not stale data. Treat this as a live lookup convenience, not yet a system of record for compliance/audit purposes.
curl "https://payment-server.wasktechnologies.com/api/v1/apps/settlements/?merchant=b6a9b3d0-1234-4a1b-9c2e-8f6a2d5c9e10" \ -H "X-Elro-App-Key: epk_live_..."
{
"results": [
{
"id": "2071987",
"status": "success",
"currency": "GHS",
"total_amount": "150.00",
"total_fees": "2.93",
"effective_amount": "147.07",
"total_transactions": 1,
"settlement_date": "2026-09-16",
"created_at": "2026-09-15T00:12:04.000Z"
}
],
"meta": { "total": 1, "skipped": 0, "perPage": 50, "page": 1, "pageCount": 1 }
}
The individual charges that make up one settlement, each with what Paystack actually deducted in fees for that specific transaction.
Query parameters
| Param | Type | Description | |
|---|---|---|---|
| merchant | uuid | required | Id of the merchant this settlement belongs to. Must belong to your app. |
| page | integer | optional | Default 1. |
Response fields
| Field | Type | Description |
|---|---|---|
| results[].reference | string | Matches the reference on Get payment status, when this charge originated on Elro Pay. |
| results[].amount | string | GHS, gross. |
| results[].fees | string | GHS, deducted from this specific transaction. |
| results[].status | string | Paystack's own charge status at settlement time. |
| results[].channel | string | mobile_money, card, etc. |
| results[].paid_at | string | ISO 8601 timestamp. |
curl "https://payment-server.wasktechnologies.com/api/v1/apps/settlements/2071987/transactions/?merchant=b6a9b3d0-1234-4a1b-9c2e-8f6a2d5c9e10" \ -H "X-Elro-App-Key: epk_live_..."
{
"results": [
{
"reference": "PAY-9F3C7A21B8D4",
"amount": "150.00",
"fees": "2.93",
"currency": "GHS",
"status": "success",
"channel": "mobile_money",
"paid_at": "2026-09-15T00:08:11.000Z"
}
],
"meta": { "total": 1, "skipped": 0, "perPage": 50, "page": 1, "pageCount": 1 }
}
Receiving updates
You don't have to poll for status changes: Elro Pay pushes them. When a payment or payout you initiated resolves, Elro Pay sends a signed POST to your callback URL. Nothing is ever broadcast to any other app.
The payload is deliberately minimal: reference, status, amount, currency, and your own user_reference, plus a type field ("payment" or "transfer") so one receiver can tell the two apart. Nothing about the merchant, recipient, or Elro Pay's internals is included.
callback_url and signing secret are set up by Elro Pay ops when your app is registered. There's no self-serve endpoint for this yet. Send your callback URL to ops to get it wired up.
POST https://your-backend.example.com/webhooks/elro Content-Type: application/json X-Elro-Signature: 91a21683b94af8dbea332f0505ce4f5ed9ac55cd366cbbbcacf0e3beea862f2 {"amount":"150.00","currency":"GHS","reference":"PAY-9F3C7A21B8D4","status":"SUCCESS","type":"payment","user_reference":"order-4821"}
POST https://your-backend.example.com/webhooks/elro Content-Type: application/json X-Elro-Signature: 5c2e14d8a91f0b3cc70e2a6b45d18f9021ab3f77c94e6d0a8b12f3c5d7e9a1b0 {"amount":"50.00","currency":"GHS","reference":"PYT-9F3C7A21B8D4","status":"SUCCESS","type":"transfer","user_reference":"cashout-9931"}
Verifying signatures
Always verify X-Elro-Signature before trusting a callback. It's an HMAC-SHA256 of the raw request body, keyed with your app's signing secret.
Use a constant-time comparison (timingSafeEqual / hmac.compare_digest), never === or ==.
const crypto = require('crypto'); function isValidElroSignature(rawBody, signatureHeader, secret) { const expected = crypto .createHmac('sha256', secret) .update(rawBody) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader) ); }
import hmac, hashlib def is_valid_elro_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature_header)
Payment statuses
| Status | Meaning |
|---|---|
| PENDING | Created, awaiting the customer to complete checkout. |
| SUCCESS | Paystack confirmed the charge. |
| FAILED | The charge was declined or errored. |
| ABANDONED | The customer left checkout without completing it. |
| EXPIRED | The checkout session timed out. |
Payout statuses have their own table, in context, at Get payout status. They include two states (REVERSED, REQUIRES_OTP) that don't apply to a collection.
Idempotency
Every write endpoint that could otherwise be called twice by accident (a client retry after a timeout, a flaky connection) accepts a user_reference you control, and treats a repeat call with the same value as the same request: it returns the original result instead of creating a duplicate.
On Initiate a payment and Send a payout, this is enforced at the database level, not just checked in application code: two truly simultaneous requests with the same user_reference can't both succeed. On Register a recipient, the same guarantee applies keyed on the account itself (account_number + bank_code), since there's no separate user_reference there.
Rate limits
Most endpoints throttle per source IP, not per app credential. If you're behind a shared NAT or proxy with high volume, be aware limits are coarser than "per app" for these.
Send a payout is throttled differently, on purpose: it's keyed to your client app credential specifically, currently 20 requests per hour, since it's the one endpoint where a leaked X-Elro-App-Key translates directly into money leaving the account. A shared IP does not affect this limit; it's yours alone.
| Status | Meaning |
|---|---|
| 429 | Rate limit exceeded. Back off and retry after a short delay; repeated bursts don't extend the window. |
Per-credential throttling on the remaining endpoints is planned but not yet built; ask ops if your integration needs it sooner.