E
Elro Pay API
v1
Client app integration reference

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.

REST · JSON Header-based auth HMAC-signed callbacks GHS via Paystack Multi-tenant, isolated by default

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.

How this differs from Paystack's own API: you're not talking to Paystack directly. Elro Pay sits in front of it, holds the Paystack secret key on your behalf, and adds the one thing Paystack doesn't: strict isolation between the multiple apps sharing this service, plus a per-app payout ledger so one app can never spend money a different app's merchants collected.

Quickstart

The shape of a typical integration, start to finish. Each step links to its full reference further down this page.

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

  2. (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.

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

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

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

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


GET /banks/ghana

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

ParamTypeDescription
typestringoptionalall (default), bank, or mobile_money.
force_refreshbooleanoptionalBypass the 24h cache and re-fetch from Paystack. Rarely needed, since bank/network lists change infrequently.

Response fields

FieldTypeDescription
data.banks[]arrayTraditional banks, present unless type=mobile_money.
data.mobile_money[]arrayMobile money networks, present unless type=bank.
  .codestringPass this as bank_code elsewhere.
  .namestringDisplay name.
  .activebooleanWhether Paystack currently accepts this code.
Different error shape on this endpoint. A bad 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.
Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/banks/ghana?type=mobile_money"
Response 200
{
  "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"
}
Response, bad type 400
{
  "success": false,
  "message": "Invalid type. Use: all, bank, or mobile_money"
}
GET /banks/all

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

ParamTypeDescription
countrystringoptionalFilter to one country, e.g. ghana. Omit for every supported country.
typestringoptionalall (default), bank, or mobile_money.
force_refreshbooleanoptionalBypass the cache and re-fetch from Paystack.

Response fields

FieldTypeDescription
data.banks[]arrayTraditional banks, present unless type=mobile_money.
data.mobile_money[]arrayMobile money networks, present unless type=bank.
countries[]arrayEvery country present in this response, e.g. ["nigeria","ghana",...].
Same error shape as List Ghana banks & mobile money: {"success": false, "message": "..."} on a bad type, or a 404 with the same shape if country matches nothing.
Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/banks/all?country=ghana&type=mobile_money"
Response 200
{
  "success": true,
  "message": "All banks retrieved successfully",
  "data": {
    "mobile_money": [
      { "code": "MTN", "name": "MTN", "active": true }
    ]
  },
  "countries": ["nigeria", "ghana", "kenya", "south africa"]
}
GET /banks/resolve

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

ParamTypeDescription
account_numberstringrequiredThe bank account number, or the mobile money number for mobile money.
bank_codestringrequiredThe bank's Paystack code, or the network code (e.g. MTN) for mobile money.

Response fields

FieldTypeDescription
data.account_numberstringEchoed back.
data.account_namestringThe real, registered name on the account.
data.bank_idintegerPaystack's internal numeric id for the bank/network.
Don't know the code? 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.

Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/banks/resolve?account_number=0551234567&bank_code=MTN" \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "success": true,
  "message": "Account resolved successfully",
  "data": {
    "account_number": "0551234567",
    "account_name": "Kwame Mensah",
    "bank_id": 28
  }
}
POST /subaccounts/create/ staff only

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:

  1. Ops resolves the merchant's bank/mobile-money account and creates its Paystack sub-account.
  2. Elro Pay provisions a brand-new client app dedicated to that merchant, which is what isolates it from every other merchant on the platform.
  3. 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.
This is your one shot at the key. Elro Pay never stores the raw value, only a one-way hash, so if it's lost, ops has to issue a fresh one (which is safe and instant, but the old key still has to be revoked separately if it might have leaked).

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

Response 201
{
  "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.

Header
X-Elro-App-Key: epk_live_3f9a1c2d5e6b7a80k7Hs9vQ2pXyZAbCdEfGhIjKlMnOpQrStUvWx

Base URL

EnvironmentURL
Productionhttps://payment-server.wasktechnologies.com/api/v1
Local / devhttp://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.

One family of endpoints differs: /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.
StatusMeaning
201Resource created (payment initiated, payout sent, recipient registered).
200Request succeeded.
400Validation failed, or a business-rule check failed (e.g. insufficient payout balance, no payout account configured).
403Missing, malformed, revoked, or invalid credential.
404Not found, including a valid id that belongs to a different app.
429Rate limit exceeded. See Rate limits.
502Paystack itself is unreachable or returned an error.

POST /apps/payments/initiate/

Starts a Paystack charge against one of your merchants. Redirect your customer to the returned authorization_url to complete payment.

Body parameters

FieldTypeDescription
merchantuuidrequiredId of the merchant being paid. Must belong to your app.
amountstringrequiredCharge amount in GHS, e.g. "150.00". Not pesewas. Minimum ₵0.01, no configured maximum.
customer_emailstringrequiredYour customer's email, passed straight to Paystack.
user_referencestringoptionalYour own internal user/order id. Stored verbatim, echoed back on status checks and callbacks.
metadataobjectoptionalFreeform, forwarded to Paystack's own transaction metadata.

Response fields

FieldTypeDescription
successbooleanAlways true on a 201.
authorization_urlstringRedirect your customer here to complete checkout.
referencestringThis payment's id. Use it with Get payment status.
access_codestringPaystack's own access code, useful only if you're embedding their inline checkout widget instead of redirecting.
Anti-enumeration: pass a merchant id that isn't yours and you get a plain 404, identical to an id that doesn't exist.
Request curl
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"}
  }'
Response 201
{
  "success": true,
  "authorization_url": "https://checkout.paystack.com/abc123xyz",
  "reference": "PAY-9F3C7A21B8D4",
  "access_code": "0peioxfhpn"
}
GET /apps/payments/status/{reference}/

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

FieldTypeDescription
referencestringThis payment's id.
user_referencestringWhatever you supplied at initiate time.
amountstringGHS.
currencystringAlways "GHS" today.
statusstringSee Payment statuses.
customer_emailstringAs supplied at initiate time.
authorization_urlstringThe same checkout URL from initiate, for reference.
paid_atstring | nullISO 8601 timestamp once status is SUCCESS, else null.
created_atstringISO 8601 timestamp.
Request curl
curl https://payment-server.wasktechnologies.com/api/v1/apps/payments/status/PAY-9F3C7A21B8D4/ \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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"
}
GET /apps/payments/

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

ParamTypeDescription
statusstringoptionalOne of the payment statuses, see reference below.
user_referencestringoptionalFilter to a single order/user id you supplied at initiate time.
pageintegeroptionalDefault 1.
page_sizeintegeroptionalDefault 25, max 100.

Response fields

FieldTypeDescription
countintegerTotal matching results across all pages.
nextstring | nullURL of the next page, or null on the last page.
previousstring | nullURL of the previous page, or null on the first.
results[]arraySame shape as Get payment status's response.
Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/apps/payments/?status=SUCCESS&user_reference=order-4821" \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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"
    }
  ]
}

POST /apps/payouts/recipients/create/

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

FieldTypeDescription
account_numberstringrequiredThe bank account number, or the mobile money number.
bank_codestringrequiredThe bank's Paystack code, or the network code (e.g. MTN). See List banks & mobile money.

Response fields

FieldTypeDescription
iduuidReference this in Send a payout.
typestringmobile_money or bank, determined automatically.
namestringReal account holder name, from Paystack's own resolution.
account_numberstringEchoed back.
bank_namestringDisplay name for the bank/network.
recipient_codestringPaystack's own recipient code, for your records.
is_activebooleanfalse if a later re-resolve found the name no longer matches.
created_atstringISO 8601 timestamp.
No 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.

Request curl
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"
  }'
Response 201
{
  "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"
}
GET /apps/payouts/recipients/

List your app's own registered payout recipients. No parameters.

Response fields

FieldTypeDescription
count / next / previous–Standard pagination, same shape as List payments.
results[]arraySame shape as Register a recipient's response.
Request curl
curl https://payment-server.wasktechnologies.com/api/v1/apps/payouts/recipients/ \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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"
    }
  ]
}
POST /apps/payouts/initiate/

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

FieldTypeDescription
recipientuuidrequiredId of a recipient from Register a recipient. Must belong to your app.
amountstringrequiredAmount 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).
reasonstringoptionalShown on the recipient's bank/mobile-money statement.
user_referencestringoptionalYour own idempotency key. Calling this twice with the same value returns the original payout instead of sending money twice.

Response fields

FieldTypeDescription
successbooleanAlways true on a 201. requires_otp counts as success, the payout record is real.
statusstringpending or requires_otp. Poll status for the final outcome.
referencestringThis payout's id.
transfer_codestringPaystack's own transfer code, present when status is pending.
messagestringPresent 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

StatusMeaning
201Payout created, status pending or requires_otp.
400Validation failed, or your app doesn't have enough balance for this payout.
404The recipient id doesn't exist, isn't yours, or was deactivated (see Register a recipient's name-mismatch case).
502Paystack 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.

Request curl
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"
  }'
Response 201
{
  "success": true,
  "status": "pending",
  "reference": "PYT-9F3C7A21B8D4",
  "transfer_code": "TRF_1ptvuv321ahaa7q"
}
Response, pending confirmation 201
{
  "success": true,
  "status": "requires_otp",
  "reference": "PYT-9F3C7A21B8D4",
  "message": "Transfer created, pending manual confirmation."
}
GET /apps/payouts/status/{reference}/

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

FieldTypeDescription
referencestringThis payout's id.
user_referencestringWhatever you supplied at initiate time.
amountstringGHS.
currencystringAlways "GHS" today.
statusstringSee the statuses table below.
reasonstringAs supplied at initiate time.
recipientobjectNested recipient summary, same shape as Register a recipient's response.
completed_atstring | nullISO 8601 timestamp once status is SUCCESS, else null.
created_atstringISO 8601 timestamp.

Payout statuses

StatusMeaning
PENDINGSubmitted to Paystack, not yet confirmed.
SUCCESSPaystack confirmed the money moved.
FAILEDThe transfer was declined or errored.
REVERSEDPaystack sent the money back to the platform balance after it couldn't be delivered.
REQUIRES_OTPHeld for a one-time confirmation on Elro Pay's side. Not an error, see Send a payout.
Request curl
curl https://payment-server.wasktechnologies.com/api/v1/apps/payouts/status/PYT-9F3C7A21B8D4/ \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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"
}
GET /apps/payouts/

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

ParamTypeDescription
statusstringoptionalOne of the payout statuses above.
user_referencestringoptionalFilter to a single payout id you supplied at initiate time.
pageintegeroptionalDefault 1.
page_sizeintegeroptionalDefault 25, max 100.
Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/apps/payouts/?status=SUCCESS" \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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"
    }
  ]
}

GET /apps/settlements/

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

ParamTypeDescription
merchantuuidrequiredId of the merchant to list settlements for. Must belong to your app.
statusstringoptionalOne of success, processing, pending, failed.
pageintegeroptionalDefault 1.

Response fields

FieldTypeDescription
results[].idstringPaystack's settlement id. Use with List settlement transactions.
results[].statusstringsuccess, processing, pending, or failed.
results[].total_amountstringGHS, gross, before fees.
results[].total_feesstringGHS, what Paystack deducted.
results[].effective_amountstringGHS, what actually landed in the merchant's bank: total_amount − total_fees.
results[].total_transactionsintegerNumber of charges rolled into this settlement.
results[].settlement_datestringDate the settlement was (or will be) paid out.
metaobjectPaystack's own pagination shape, see the callout below.
Different pagination shape on purpose. This endpoint proxies Paystack's own settlement list live, so its 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.

Request curl
curl "https://payment-server.wasktechnologies.com/api/v1/apps/settlements/?merchant=b6a9b3d0-1234-4a1b-9c2e-8f6a2d5c9e10" \
  -H "X-Elro-App-Key: epk_live_..."
Response 200
{
  "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 }
}
GET /apps/settlements/{id}/transactions/

The individual charges that make up one settlement, each with what Paystack actually deducted in fees for that specific transaction.

Query parameters

ParamTypeDescription
merchantuuidrequiredId of the merchant this settlement belongs to. Must belong to your app.
pageintegeroptionalDefault 1.

Response fields

FieldTypeDescription
results[].referencestringMatches the reference on Get payment status, when this charge originated on Elro Pay.
results[].amountstringGHS, gross.
results[].feesstringGHS, deducted from this specific transaction.
results[].statusstringPaystack's own charge status at settlement time.
results[].channelstringmobile_money, card, etc.
results[].paid_atstringISO 8601 timestamp.
Anti-enumeration: a settlement id that exists but isn't one of this merchant's returns the same 404 as one that doesn't exist at all.
Request curl
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_..."
Response 200
{
  "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.

Currently ops-configured: your 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.
Incoming request — payment
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"}
Incoming request — payout
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 ==.

Node.js
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)
  );
}
Python
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

StatusMeaning
PENDINGCreated, awaiting the customer to complete checkout.
SUCCESSPaystack confirmed the charge.
FAILEDThe charge was declined or errored.
ABANDONEDThe customer left checkout without completing it.
EXPIREDThe 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.

StatusMeaning
429Rate 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.