MerbPay is a payment gateway API for accepting payments, sending payouts, and managing merchant treasury operations. This reference covers authentication, accepting a payment, payment links, webhooks, refunds, withdrawals, bulk payouts, and customer management.
Every request in this reference is made against:
https://api.merebpay.comHosted checkout pages and payment links (the paymentUrl / generatedUrl your customers land on) are served from https://checkout.merebpay.com.
isTest flag instead. See Sandbox vs. live mode./api/v1/apiKeyRequires dashboard session (JWT)Keys are created from the dashboard, not via a bootstrap API call — this endpoint requires a logged-in session that holds the PER_ADD_API_KEY permission.
| Field | Type | Required | Description |
|---|---|---|---|
apiName | string | Yes | A label for this key (e.g. “Production Backend”) |
testApi | boolean | Yes | true for a sandbox key, false for live |
apiKeyType | string | No | PAYMENT or WITHDRAWAL. Defaults to PAYMENT. |
testApi: false) key requires your business to have completed KYC Level 2. Sandbox keys have no such requirement.curl -X POST https://api.merebpay.com/api/v1/apiKey \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"apiName": "Production Backend",
"testApi": false,
"apiKeyType": "PAYMENT"
}'{
"status": 200,
"message": "Saved api key",
"content": {
"apiKeyId": 42,
"apiKey": "K1lz...redacted-88-char-base64...==",
"enabled": true,
"adminEnabled": true,
"apiName": "Production Backend",
"apiKeyType": "PAYMENT",
"testApi": false,
"companyId": 21,
"creationDate": "2026-08-20T09:00:00Z"
}
}apiKey field entirely. Store it securely the moment you receive it — if you lose it, you must generate a new one; there is no recovery endpoint.Keys are a 64-byte random value, standard Base64-encoded (~88 characters). There is no sk_live_ / sk_test_ style prefix — whether a key is test or live is tracked only by the testApi flag on the server side, not by anything visible in the key string itself. Treat every key as sensitive regardless of its testApi value.
Pass it on every request as a header — not as a bearer token, not as a query param:
apiKey: YOUR_API_KEYcurl -X POST https://api.merebpay.com/api/v1/payment/request/api \
-H "apiKey: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ ... }'Requests can additionally be restricted to specific source IPs. If you never add any whitelist entries, requests are not IP-restricted.
/api/v1/whitelisting/ips/api/v1/whitelisting/ips/add/api/v1/whitelisting/ips/{id}/api/v1/whitelisting/ips/{id}| Field | Type | Required | Description |
|---|---|---|---|
ipv4 | string | Yes | An IP or CIDR block, e.g. 203.0.113.4 or 203.0.113.0/24 |
note | string | No | Internal label, up to 500 characters |
test | boolean | Yes | Whether this entry applies to your test or live key |
test: false) whitelist entries go into a pending state and require admin approval before they take effect. Test entries resolve immediately (approved or denied at random) so you can develop without waiting on a human — but that outcome doesn't predict what happens on the live side.Every request that creates money movement — a payment request, a withdrawal, a payment link — accepts an isTest (or test) boolean. When true:
test: true and excluded from your live analytics/reporting totals.There is no separate sandbox subdomain or sandbox-only API key — the same credentials and the same base URL are used for both; the isTest flag on each request is what routes it.
/api/v1/payment/request/apiRequires apiKey headerCreates a hosted checkout session and returns a payment URL to redirect your customer to.
| Field | Type | Required | Description |
|---|---|---|---|
method | string | Yes | LINK (hosted checkout page) or USSD_PUSH |
paymentMethodCode | string | Yes | e.g. TELE_BIRR, CBE, M_PESA, E_BIRR, TELE_BIRR_USSD |
currency | string | Yes | ETB or USD |
customerName | string | No | Shown on the checkout page and receipt |
customerPhone | string | No | Include the country code, e.g. 251985968554 |
amount | number | Yes | Two decimal places |
orderId | string | Yes | Your own unique reference for this order |
description | string | No | Shown to the customer at checkout |
isTest | boolean | Yes | true for sandbox |
curl -X POST https://api.merebpay.com/api/v1/payment/request/api \
-H "Content-Type: application/json" \
-H "apiKey: YOUR_API_KEY" \
-d '{
"method": "LINK",
"paymentMethodCode": "TELE_BIRR",
"currency": "ETB",
"customerName": "Abebe Kebede",
"customerPhone": "251985968554",
"amount": 500.00,
"orderId": "order-10293",
"description": "Order #10293",
"isTest": true
}'{
"status": 200,
"message": "Payment request generated successfully",
"content": {
"paymentRequestId": 133,
"systemReference": "e9ad7c34ddfa4f12b437ee33f1e67acc",
"externalReference": null,
"method": "LINK",
"status": "PENDING",
"customerName": "Abebe Kebede",
"customerPhone": "251985968554",
"amount": 500.00,
"currency": "ETB",
"orderId": "order-10293",
"description": "Order #10293",
"paymentUrl": "https://checkout.merebpay.com/payment/checkout/e9ad7c34...",
"requestDate": "2026-08-20T09:00:00Z",
"expiryDate": "2026-08-21T09:00:00Z",
"completionDate": null,
"isTest": true
}
}Redirect your customer to paymentUrl to complete payment on the hosted checkout page. systemReference is how you'll look this transaction up afterward, and is also the ID used in webhook events for this transaction.
/api/v1/transaction/receipt/{referenceId}Public — no authThis is what powers the printable receipt page and is safe to link customers to directly.
// GET /api/v1/transaction/receipt/{referenceId} — public, no auth required
{
"status": 200,
"message": "Transaction detail",
"content": {
"referenceId": "e9ad7c34ddfa4f12b437ee33f1e67acc",
"status": "SUCCESS",
"baseAmount": 500.00,
"totalAmount": 500.00,
"commissionAmount": 12.50,
"vatAmount": 1.88,
"customerName": "Abebe Kebede",
"customerPhone": "251985968554",
"paymentType": "TELE_BIRR",
"paymentChannel": "PaymentLink",
"paymentDate": "2026-08-20T09:05:22Z",
"amountInWords": "five hundred 00/100 birr and zero 00/100 cents"
}
}status is one of PENDING, PROCESSING, SUCCESS, FAILED, EXPIRED.
In test mode, there's no real Telebirr/CBE/etc. to complete the payment through. Instead:
/api/v1/payment/sandbox/checkout/payRequires apiKey header| Field | Type | Required | Description |
|---|---|---|---|
paymentMethodId | number | Yes | The numeric ID of the payment method type |
paymentIntentUniqueId | string | Yes | The systemReference from Create a payment request |
phone | string | No | — |
testMode.testModeType | string | Yes | SIMULATED_TEST |
testMode.simulationStatus | string | Yes | SUCCESS or FAILED — force the outcome you want to test |
curl -X POST https://api.merebpay.com/api/v1/payment/sandbox/checkout/pay \
-H "apiKey: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"paymentMethodId": 1,
"paymentIntentUniqueId": "e9ad7c34ddfa4f12b437ee33f1e67acc",
"phone": "251985968554",
"testMode": { "testModeType": "SIMULATED_TEST", "simulationStatus": "SUCCESS" }
}'Use simulationStatus: FAILED to test your failure-handling path without needing a real declined payment.
A payment link is a reusable checkout URL you can share (QR code, invoice, social post) instead of generating a new payment request per order.
/api/v1/payment/linkRequires apiKey header| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | — |
linkType | string | Yes | FIXED or FREE_FORM |
description | string | No | — |
commissionPaidByCustomer | boolean | No | Default false |
image | string | No | — |
isTest | boolean | No | Default false |
price field on creation — the API rejects it. Every link is created with a flexible, customer-entered amount; if you need a fixed price, set it afterward via PATCH /api/v1/payment/link/{id}, which requires price.{
"status": 200,
"message": "Created payment link",
"content": {
"paymentLinkId": 88,
"title": "Support Our Cause",
"price": null,
"commissionPaidByCustomer": false,
"description": "One-time or recurring donation",
"linkType": "FREE_FORM",
"image": null,
"category": "CUSTOM",
"generatedUrl": "https://checkout.merebpay.com/payment-link/a1b2c3d4"
}
}/api/v1/payment/link/merchant/search/api/v1/payment/link/{id}/api/v1/payment/link/{id}Route a percentage or fixed amount of a payment to another party automatically — a vendor, a partner, a payee you don't want to pay out manually every time. Register the recipient once as a subaccount, then reference it on any future payment. The recipient is paid out on your own normal settlement cycle, alongside your own funds — not instantly.
splitType/value fresh. One recipient can be used with a 10% split on one payment and a flat 50 ETB split on the next./api/v1/subaccountRequires apiKey header (PAYMENT type)| Field | Type | Required | Description |
|---|---|---|---|
label | string | No | Free text, your own reference for this recipient |
accountNumber | string | Yes | Validated against the format expected for paymentMethodCode |
accountHolder | string | Yes | Name on the account |
bankId | number | Yes | From GET /api/v1/bank |
paymentMethodCode | string | Yes | One of TELE_BIRR, CBE, M_PESA, E_BIRR, TELE_BIRR_USSD, NIB_PNR, CBE_PNR — the payout-capable subset of payment methods |
curl -X POST https://api.merebpay.com/api/v1/subaccount \
-H "apiKey: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Acme Vendor",
"accountNumber": "1000987654",
"accountHolder": "Acme Vendor PLC",
"bankId": 1,
"paymentMethodCode": "CBE"
}'{
"status": 200,
"message": "Split recipient created",
"content": {
"splitRecipientId": 42,
"label": "Acme Vendor",
"maskedAccountNumber": "****7654",
"accountHolder": "Acme Vendor PLC",
"bankName": "Commercial Bank of Ethiopia",
"paymentMethodCode": "CBE",
"status": "ACTIVE"
}
}Add a splitPayment block to any payment request you already create (see Create a payment request) — everything else about that call stays the same.
curl -X POST https://api.merebpay.com/api/v1/payment/intent \
-H "apiKey: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 1000.00,
"currency": "ETB",
"allowedMethods": ["CBE"],
"splitPayment": {
"splitRecipientId": 42,
"splitType": "PERCENTAGE",
"value": 10
},
"returnUrl": "https://example.com/return",
"expireIn": 600,
"commissionPaidByCustomer": false
}'| Field | Type | Description |
|---|---|---|
splitRecipientId | number | The subaccount to pay |
splitType | "PERCENTAGE" | "NUMBER" | PERCENTAGE (0–100) is computed against the payment's gross amount; NUMBER is a fixed amount |
value | number | The percentage or fixed amount, per splitType |
/api/v1/subaccount/api/v1/subaccount/{id}/api/v1/subaccount/{id}/api/v1/subaccount/{id}/disableDisabling is a soft delete — a disabled recipient can no longer be used on a new payment, but stays attached to its historical transactions and payouts.
Every accrued split for a recipient is summed and paid into their account as part of your own next settlement run — no separate payout call needed. Refund behavior depends on whether the split was already paid out:
Webhooks let you receive real-time notifications instead of polling the receipt endpoint.
/api/v1/webhook?test={true|false}Requires apiKey header| Field | Type | Required | Description |
|---|---|---|---|
webHookUrl | string | Yes | Where events for this mode (test/live) get delivered |
webHookPubk | string | Yes | Your RSA public key, Base64-encoded |
webHookPpk | string | Yes | Your RSA private key, Base64-encoded |
test query param selects which)./api/v1/webhook/regenerate?test={true|false}Requires apiKey headerGenerates a brand-new key pair server-side. Returns only a success message — the new keys are not included in the response, so retrieve them immediately after via the endpoint below.
/api/v1/webhook/search?test={true|false}Requires apiKey header// GET /api/v1/webhook/search?test={true|false}
{
"status": 200,
"message": "...",
"content": {
"webHookUrl": "https://yourapp.com/webhooks/merebpay",
"webHookPubk": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
}
}Your private key is never returned by any API call after the moment you upload it — keep your own copy.
We send a POST to your registered URL with this envelope:
{
"webHookEvent": "PAYMENT_SUCCESS",
"signature": "base64-rsa-signature-of-the-body-field",
"body": "{\"paymentIntentId\":123,\"uuid\":\"...\",\"transaction\":{...},\"totalAmount\":500.00,\"currency\":\"ETB\", ...}"
}| Field | Description |
|---|---|
webHookEvent | PAYMENT_SUCCESS, PAYMENT_FAILED, PAYMENT_EXPIRED, WITHDRAWAL_SUCCESS, WITHDRAWAL_FAILED |
signature | Base64-encoded RSA signature — see Verifying the signature |
body | A JSON-encoded string, not a nested object. Parse this a second time to get the actual transaction data. |
body is a JSON-encoded string, not a nested object. This trips people up: JSON.parse(envelope.body) a second time to get the real payload. It's sent this way so the exact bytes that were signed are unambiguous — signing a re-serialized object could produce different bytes than what was actually signed, depending on key ordering.The parsed body contains (among other fields): paymentIntentId, uuid, totalAmount, currency, test, transaction (with referenceId, transactionStatus, merchantNet, adminNet, paymentType), and merchantCustomer (name, email, phone).
Algorithm: SHA256withRSA. The signature is computed over the raw body string (before you parse it) and Base64-encoded.
const crypto = require('crypto');
function verifyWebhook(envelope, yourStoredPublicKeyPem) {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(envelope.body, 'utf8');
verifier.end();
return verifier.verify(yourStoredPublicKeyPem, envelope.signature, 'base64');
}
app.post('/webhooks/merebpay', (req, res) => {
if (!verifyWebhook(req.body, MY_MEREB_PUBLIC_KEY)) {
return res.status(400).send('invalid signature');
}
const data = JSON.parse(req.body.body); // body is a JSON string — parse twice
// handle data.transaction.transactionStatus, etc.
res.status(200).send('ACK'); // must be 2xx AND contain ACK / SUCCESS / OK
});signature field, verified against the body field.ACK, SUCCESS, or OK. A bare 200 OK with an empty body does not count as acknowledged. res.status(200).send('ACK') is sufficient.If delivery isn't acknowledged, we retry automatically: up to 3 attempts with a 5-second backoff between attempts. If all attempts are exhausted, the event is marked failed on our side — implement idempotent handling on your end in case of any edge-case duplicate delivery, and reach out to support if you notice a webhook you never received.
Refunds go through a request → admin-approval flow; they are not instant.
/api/v1/merchant/refundRequires apiKey header| Field | Type | Required | Description |
|---|---|---|---|
transactionReferenceId | string | Yes | The referenceId of the original transaction |
amount | number | Yes | Must be > 0 and ≤ the transaction's remaining refundable amount |
reason | string | Yes | — |
curl -X POST https://api.merebpay.com/api/v1/merchant/refund \
-H "apiKey: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transactionReferenceId": "e9ad7c34ddfa4f12b437ee33f1e67acc",
"amount": 200.00,
"reason": "Customer requested partial refund"
}'// 201 Created
{
"status": 201,
"message": "Refund request submitted",
"content": {
"referenceId": "REF-a1b2c3d4",
"status": "PENDING_APPROVAL"
}
}Refund status progresses through: PENDING_APPROVAL → APPROVED → PROCESSING → REFUNDED (or REJECTED/FAILED).
SUCCESS, must belong to your company, only one active refund can exist per transaction at a time, and a transaction with an unresolved chargeback or one already swept into a completed settlement run cannot be refunded./api/v1/merchant/refund/api/v1/merchant/refund/{referenceId}/api/v1/withdrawlive/api/v1/withdraw/sandboxtest mode| Field | Type | Required | Description |
|---|---|---|---|
bankAccountId | number | Yes | An approved bank account already on file for your company |
amount | number | Yes | Two decimal places |
method | string | No | Payment method code for the payout channel |
422 with a specific message (“Insufficient balance for withdrawal.” or the daily-limit message) rather than a generic error.Unlike Withdrawals above, which pays out to a bank account you've already registered on your dashboard, a Transfer sends money directly to any bank account or mobile wallet you specify in the request — no prior registration needed. Requires a separate WITHDRAWAL-type API key (same key family as your PAYMENT key, different type) and withdrawal_enabled on your account.
/api/v1/balanceRequires apiKey header (any type)curl https://api.merebpay.com/api/v1/balance \
-H "apiKey: YOUR_API_KEY"/api/v1/bankRequires apiKey header (any type)curl https://api.merebpay.com/api/v1/bank \
-H "apiKey: YOUR_API_KEY"/api/v1/payment/express/withdrawlive/api/v1/payment/express/sandbox/withdrawtest mode| Field | Type | Required | Description |
|---|---|---|---|
amount | number | Yes | 10–25,000 ETB per Transfer |
creditAccount | string | Yes | Destination account number or wallet phone number — no prior registration |
method | string | Yes | Payout channel, e.g. CBE, TELE_BIRR |
withdrawalIntentUniqueId | string | Yes | Your own reference — used to check status later |
curl -X POST https://api.merebpay.com/api/v1/payment/express/withdraw \
-H "apiKey: YOUR_WITHDRAWAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 500.00,
"currency": "ETB",
"withdrawalIntentUniqueId": "payout-2026-10-08-001",
"creditAccount": "0911223344",
"phoneNumber": "0911223344",
"method": "TELE_BIRR",
"expireIn": 600,
"commissionPaidByCustomer": false,
"customerInfo": { "phone": "0911223344", "name": "Recipient Name" }
}'/api/v1/payment/express/withdraw/statuslive/api/v1/payment/express/withdraw/status/sandboxtest modecurl -X POST https://api.merebpay.com/api/v1/payment/express/withdraw/status \
-H "apiKey: YOUR_WITHDRAWAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "withdrawalIntentUniqueId": "payout-2026-10-08-001" }'{
"status": 200,
"message": "Fetched transfer status",
"content": {
"uniqueId": "payout-2026-10-08-001",
"status": "SUCCESS",
"totalAmount": 500.00,
"merchantNet": 500.00,
"creditAccount": "0911223344",
"currency": "ETB"
}
}/api/v1/payment/express/withdrawRequires apiKey header (WITHDRAWAL type)curl "https://api.merebpay.com/api/v1/payment/express/withdraw?fromDate=2026-10-01T00:00:00&toDate=2026-10-08T23:59:59&status=SUCCESS" \
-H "apiKey: YOUR_WITHDRAWAL_API_KEY"Optional query params: fromDate, toDate, currency, status.
For paying out many recipients (e.g. payroll, disbursements) in one batch via a CSV/Excel upload.
3 columns, no fixed header requirement — a header row is auto-detected and skipped if present.
| Column | Contains |
|---|---|
| 1 | Recipient name |
| 2 | Destination account number |
| 3 | Amount |
Each row is independently verified against the destination account before the batch can proceed:
PENDING, awaiting admin approval.AWAITING_CONFIRMATION — you choose whether to proceed with just the valid rows or discard the batch.A row can fail verification for: the account not existing, the declared name not matching the account holder on file, or the account existing but not in an active state (dormant/closed/blocked).
MerbPay automatically records a customer profile the first time someone pays through your checkout, matched by email, then phone, then name (in that priority order) — you don't need to create customers explicitly.
/api/v1/merchant/customersearch/list/api/v1/merchant/customer/{customerId}/api/v1/merchant/customer/transaction/api/v1/merchant/customer/uniqueGET /api/v1/merchant/customer)| Field | Type | Required | Description |
|---|---|---|---|
name, email, phone | string | No | Filter by any combination |
fromDate, toDate | ISO 8601 datetime | No | Date range |
pageNumber, pageSize | number | No | Pagination |
status field in every response body, not just the HTTP status code — most errors return a status matching the HTTP status, but the response shape varies by error type (see below), so don't assume every error has exactly the same fields.{ "status": 400, "message": "A specific description of what went wrong." }{
"status": 400,
"message": "Validation error occurred while processing request object.",
"validationErrors": { "amount": "must not be null" }
}| Situation | HTTP Status | Message |
|---|---|---|
| Missing/malformed apiKey header | 400 | “Unable to find the merchant api key from the request...” |
| API key not recognized | 404 | “No valid api key found.” |
| Request from a non-whitelisted IP | 403 | “Ip address is not whitelisted...” |
| Account disabled | 403 | “Merchant is disabled, please check with admin.” |
| Refund exceeds remaining refundable amount | 400 | “Amount cannot exceed the remaining refundable amount of ...” |
| Duplicate active refund on same transaction | 409 | “An active refund already exists for this transaction” |
| Insufficient balance for withdrawal | 422 | “Insufficient balance for withdrawal.” |
| Daily withdrawal limit reached | 422 | “Maximum allowed daily limit reached...” |
| Unexpected server error | 500 | “Sorry, an unexpected error occurred. Please check your request and try again.” |
Always build your integration to read message for the human-readable reason rather than branching only on HTTP status — several different failure conditions can share the same status code.
| Code | Provider |
|---|---|
TELE_BIRR | Tele Birr |
TELE_BIRR_USSD | Tele Birr via USSD push |
CBE | Commercial Bank of Ethiopia |
M_PESA | M-Pesa |
E_BIRR | eBirr |