Introduction

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.

Base URL

Every request in this reference is made against:

https://api.merebpay.com

Hosted checkout pages and payment links (the paymentUrl / generatedUrl your customers land on) are served from https://checkout.merebpay.com.

Unlike some gateways, sandbox vs. live is not a different host or a differently-prefixed key. The same API key and the same base URL are used for both — each request that moves money carries an explicit isTest flag instead. See Sandbox vs. live mode.

Creating an API key

POST/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.

FieldTypeRequiredDescription
apiNamestringYesA label for this key (e.g. “Production Backend”)
testApibooleanYestrue for a sandbox key, false for live
apiKeyTypestringNoPAYMENT or WITHDRAWAL. Defaults to PAYMENT.
Creating a live (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"
  }'

Response

{
  "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"
  }
}
The raw key is shown exactly once, in this response. Every other call that returns key metadata (list, fetch, update) redacts the 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.

Using your API key

Pass it on every request as a header — not as a bearer token, not as a query param:

apiKey: YOUR_API_KEY
curl -X POST https://api.merebpay.com/api/v1/payment/request/api \
  -H "apiKey: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

IP whitelisting

Requests can additionally be restricted to specific source IPs. If you never add any whitelist entries, requests are not IP-restricted.

GET/api/v1/whitelisting/ips
POST/api/v1/whitelisting/ips/add
PATCH/api/v1/whitelisting/ips/{id}
DELETE/api/v1/whitelisting/ips/{id}
FieldTypeRequiredDescription
ipv4stringYesAn IP or CIDR block, e.g. 203.0.113.4 or 203.0.113.0/24
notestringNoInternal label, up to 500 characters
testbooleanYesWhether this entry applies to your test or live key
Live (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.

Sandbox vs. live mode

Every request that creates money movement — a payment request, a withdrawal, a payment link — accepts an isTest (or test) boolean. When true:

  • No real money moves. PSP calls are simulated.
  • Transactions are tagged test: true and excluded from your live analytics/reporting totals.
  • You can force a specific outcome using the sandbox checkout-completion endpoint, rather than waiting on a real payment method.

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.

Create a payment request

POST/api/v1/payment/request/apiRequires apiKey header

Creates a hosted checkout session and returns a payment URL to redirect your customer to.

FieldTypeRequiredDescription
methodstringYesLINK (hosted checkout page) or USSD_PUSH
paymentMethodCodestringYese.g. TELE_BIRR, CBE, M_PESA, E_BIRR, TELE_BIRR_USSD
currencystringYesETB or USD
customerNamestringNoShown on the checkout page and receipt
customerPhonestringNoInclude the country code, e.g. 251985968554
amountnumberYesTwo decimal places
orderIdstringYesYour own unique reference for this order
descriptionstringNoShown to the customer at checkout
isTestbooleanYestrue for sandbox

Example request

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
  }'

Example response

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

Checking status

GET/api/v1/transaction/receipt/{referenceId}Public — no auth

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

Completing a sandbox payment

In test mode, there's no real Telebirr/CBE/etc. to complete the payment through. Instead:

POST/api/v1/payment/sandbox/checkout/payRequires apiKey header
FieldTypeRequiredDescription
paymentMethodIdnumberYesThe numeric ID of the payment method type
paymentIntentUniqueIdstringYesThe systemReference from Create a payment request
phonestringNo—
testMode.testModeTypestringYesSIMULATED_TEST
testMode.simulationStatusstringYesSUCCESS 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.

Split Payments

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.

Unlike some gateways, a subaccount here carries no split percentage of its own — every payment declares its own splitType/value fresh. One recipient can be used with a 10% split on one payment and a flat 50 ETB split on the next.

Creating a subaccount

POST/api/v1/subaccountRequires apiKey header (PAYMENT type)
FieldTypeRequiredDescription
labelstringNoFree text, your own reference for this recipient
accountNumberstringYesValidated against the format expected for paymentMethodCode
accountHolderstringYesName on the account
bankIdnumberYesFrom GET /api/v1/bank
paymentMethodCodestringYesOne 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"
  }'

Response

{
  "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"
  }
}

Attaching a split to a payment

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
  }'
FieldTypeDescription
splitRecipientIdnumberThe subaccount to pay
splitType"PERCENTAGE" | "NUMBER"PERCENTAGE (0–100) is computed against the payment's gross amount; NUMBER is a fixed amount
valuenumberThe percentage or fixed amount, per splitType
Exactly one recipient per payment. If the resolved split amount would exceed what you'd net on the transaction after commission, the request is rejected up front — it can never push your own net negative.

Managing subaccounts

GET/api/v1/subaccount
GET/api/v1/subaccount/{id}
PATCH/api/v1/subaccount/{id}
POST/api/v1/subaccount/{id}/disable

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

Settlement & refunds

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:

  • Refund a payment whose split hasn't been paid out yet → the split is simply excluded. Nothing to claw back.
  • Refund a payment whose split has already been paid out → it's debited from that recipient's next payout instead. The original payout itself is never reversed.

Webhooks

Webhooks let you receive real-time notifications instead of polling the receipt endpoint.

Registering your webhook

PATCH/api/v1/webhook?test={true|false}Requires apiKey header
FieldTypeRequiredDescription
webHookUrlstringYesWhere events for this mode (test/live) get delivered
webHookPubkstringYesYour RSA public key, Base64-encoded
webHookPpkstringYesYour RSA private key, Base64-encoded
This endpoint sets your webhook URL and your signing key pair together — both key fields are required even if you only want to change the URL. Test and live each have their own independent URL and key pair (the test query param selects which).

Regenerating your keys

POST/api/v1/webhook/regenerate?test={true|false}Requires apiKey header

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

Retrieving your current public key

GET/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.

Receiving an event

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\", ...}"
}
FieldDescription
webHookEventPAYMENT_SUCCESS, PAYMENT_FAILED, PAYMENT_EXPIRED, WITHDRAWAL_SUCCESS, WITHDRAWAL_FAILED
signatureBase64-encoded RSA signature — see Verifying the signature
bodyA 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).

Verifying the signature

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
});
There is no separate HTTP header carrying this signature — it travels inside the JSON body as the signature field, verified against the body field.

Acknowledging delivery

A delivery counts as successful only if both: (1) you respond with a 2xx HTTP status, and (2) your response body contains — case-insensitive, anywhere in the text — one of ACK, SUCCESS, or OK. A bare 200 OK with an empty body does not count as acknowledged. res.status(200).send('ACK') is sufficient.

Retry behavior

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

Refunds go through a request → admin-approval flow; they are not instant.

POST/api/v1/merchant/refundRequires apiKey header
FieldTypeRequiredDescription
transactionReferenceIdstringYesThe referenceId of the original transaction
amountnumberYesMust be > 0 and ≤ the transaction's remaining refundable amount
reasonstringYes—
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"
  }'

Response (201 Created)

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

Rules enforced server-side: the transaction must be 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.

Checking refund status

GET/api/v1/merchant/refund
GET/api/v1/merchant/refund/{referenceId}

Withdrawals

POST/api/v1/withdrawlive
POST/api/v1/withdraw/sandboxtest mode
FieldTypeRequiredDescription
bankAccountIdnumberYesAn approved bank account already on file for your company
amountnumberYesTwo decimal places
methodstringNoPayment method code for the payout channel
Withdrawals are subject to your available wallet balance and a daily withdrawal limit tied to your KYC level. If either check fails, you'll get a 422 with a specific message (“Insufficient balance for withdrawal.” or the daily-limit message) rather than a generic error.

Transfers (ad-hoc payouts)

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.

Check your balance first

GET/api/v1/balanceRequires apiKey header (any type)
curl https://api.merebpay.com/api/v1/balance \
  -H "apiKey: YOUR_API_KEY"

Look up a bank code

GET/api/v1/bankRequires apiKey header (any type)
curl https://api.merebpay.com/api/v1/bank \
  -H "apiKey: YOUR_API_KEY"

Send the Transfer

POST/api/v1/payment/express/withdrawlive
POST/api/v1/payment/express/sandbox/withdrawtest mode
FieldTypeRequiredDescription
amountnumberYes10–25,000 ETB per Transfer
creditAccountstringYesDestination account number or wallet phone number — no prior registration
methodstringYesPayout channel, e.g. CBE, TELE_BIRR
withdrawalIntentUniqueIdstringYesYour 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" }
  }'

Check a Transfer's status

POST/api/v1/payment/express/withdraw/statuslive
POST/api/v1/payment/express/withdraw/status/sandboxtest mode
curl -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"
  }
}

List your Transfer history

GET/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.

Sandbox and live never mix: a test-mode key only ever sees test-mode Transfers in status checks and history, and a live key only live ones. There's no way to query across environments, by design.

Bulk payouts

For paying out many recipients (e.g. payroll, disbursements) in one batch via a CSV/Excel upload.

File format

3 columns, no fixed header requirement — a header row is auto-detected and skipped if present.

ColumnContains
1Recipient name
2Destination account number
3Amount

Each row is independently verified against the destination account before the batch can proceed:

  • All rows valid → batch status PENDING, awaiting admin approval.
  • Some rows valid, some invalid → batch status AWAITING_CONFIRMATION — you choose whether to proceed with just the valid rows or discard the batch.
  • No rows valid → the batch is rejected outright with “No rows passed account verification.”

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

You'll receive an in-app/email notification at each of these transition points.

Customers

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.

GET/api/v1/merchant/customersearch/list
GET/api/v1/merchant/customer/{customerId}
GET/api/v1/merchant/customer/transaction
GET/api/v1/merchant/customer/unique

Search parameters (GET /api/v1/merchant/customer)

FieldTypeRequiredDescription
name, email, phonestringNoFilter by any combination
fromDate, toDateISO 8601 datetimeNoDate range
pageNumber, pageSizenumberNoPagination

Error handling

Check the 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.

Most common shape

{ "status": 400, "message": "A specific description of what went wrong." }

Validation errors additionally include a field-level breakdown

{
  "status": 400,
  "message": "Validation error occurred while processing request object.",
  "validationErrors": { "amount": "must not be null" }
}

Common error scenarios you'll encounter

SituationHTTP StatusMessage
Missing/malformed apiKey header400“Unable to find the merchant api key from the request...”
API key not recognized404“No valid api key found.”
Request from a non-whitelisted IP403“Ip address is not whitelisted...”
Account disabled403“Merchant is disabled, please check with admin.”
Refund exceeds remaining refundable amount400“Amount cannot exceed the remaining refundable amount of ...”
Duplicate active refund on same transaction409“An active refund already exists for this transaction”
Insufficient balance for withdrawal422“Insufficient balance for withdrawal.”
Daily withdrawal limit reached422“Maximum allowed daily limit reached...”
Unexpected server error500“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.

Appendix: payment method codes

CodeProvider
TELE_BIRRTele Birr
TELE_BIRR_USSDTele Birr via USSD push
CBECommercial Bank of Ethiopia
M_PESAM-Pesa
E_BIRReBirr
Ask a question…