On this page
API reference
Introduction#
The Merkato API is a small REST API for accepting wallet payments in Ethiopia. Customers pay straight into your account, and Merkato confirms every receipt with the provider before telling your server.
Requests and responses are JSON. Amounts are decimal strings in ETB, so there's no floating point rounding. Timestamps are ISO 8601 in UTC.
- 1
Create. Your server creates a payment for the order total.
- 2
Redirect. The customer opens checkoutUrl and pays from their wallet.
- 3
Verify. Merkato checks the receipt with the provider.
- 4
Fulfil. You receive a signed payment.confirmed webhook.
https://merkato-dashboard.kalebalebachew4.workers.devSupported wallets
Authentication#
Authenticate with a secret key from the Developers page in your dashboard. Send it as a bearer token on every request.
Keys start with mkt_ followed by 64 hex characters. Merkato stores only a hash, so a key is shown once when you create it. Revoked keys stop working immediately.
curl https://merkato-dashboard.kalebalebachew4.workers.dev/api/payments/mkt_9f86d081884c7d659a2feaa0c55ad015 \
-H "Authorization: Bearer mkt_live_key_here"Errors#
Merkato uses standard HTTP status codes. Every error response has a JSON body with a human-readable error message you can log or show to an operator.
| 200 | OK | The request succeeded, or an idempotent request was replayed. |
| 201 | Created | A new payment was created. |
| 400 | Bad request | The body, header, or reference is malformed. |
| 401 | Unauthorized | The API key is missing, malformed, or revoked. |
| 404 | Not found | No payment with that reference belongs to your account. |
| 409 | Conflict | An Idempotency-Key was reused with a different body. |
| 422 | Unprocessable | No active Telebirr or CBE Birr payout account is set up. |
| 429 | Too many requests | You hit a rate limit. Back off and retry. |
| 503 | Unavailable | A temporary failure. Retry with backoff. |
// 401 Unauthorized
{
"error": "Invalid or revoked merchant API key"
}Idempotency#
Network failures happen. Send an Idempotency-Key header when creating a payment so a retry never charges a customer twice.
Keys are 8 to 128 characters of letters, digits, . : _ or -. Your order ID usually works well. Replaying a key with the same body returns the original payment with status 200 and an Idempotency-Replayed: true header. Reusing it with a different body returns 409.
curl https://merkato-dashboard.kalebalebachew4.workers.dev/api/payments \
-H "Authorization: Bearer $MERKATO_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1042" \
-d '{"amount":"850.00"}'
# Retrying with the same key and body returns the
# original payment with Idempotency-Replayed: trueRate limits#
Limits apply per merchant account over a rolling one-minute window.
| 60 | Create | POST /api/payments |
| 120 | Retrieve | GET /api/payments/:reference |
When you exceed a limit you'll get 429. Prefer webhooks over polling to stay well inside them.
Payments
The payment object#
A payment represents one order your customer pays for at checkout.
Attributes
- orderIdstring
- Unique identifier for the payment.
- referencestring
- Public reference used in the checkout URL and to retrieve the payment. Starts with
mkt_. - statusenum
PENDINGuntil paid,PAIDonce the receipt is verified, orEXPIREDif the checkout window closed first.- amountstring
- Order total in ETB, e.g. "850.00".
- currencystring
- Always
ETB. - checkoutUrlstring
- Hosted checkout page to send your customer to.
- externalReferencestring | null
- Your own order ID, if you set one.
- descriptionstring | null
- Shown to the customer at checkout.
- paymentLinkIdstring | null
- Set when the payment was started from a payment link.
- createdAttimestamp
- When the payment was created.
- expiresAttimestamp
- When checkout closes, 30 minutes after creation.
- verifiedAttimestamp | null
- When Merkato verified the receipt.
{
"orderId": "4f9c2a1e-8b7d-4c3a-9e21-6d0f5b8a7c13",
"reference": "mkt_9f86d081884c7d659a2feaa0c55ad015",
"externalReference": "order_1042",
"paymentLinkId": null,
"description": "2 × Yirgacheffe, 250g",
"amount": "850.00",
"currency": "ETB",
"status": "PENDING",
"checkoutUrl": "https://merkato-checkout.kalebalebachew4.workers.dev/pay/mkt_9f86…",
"createdAt": "2026-10-29T18:00:00.000Z",
"expiresAt": "2026-10-29T18:30:00.000Z",
"verifiedAt": null
}Create a payment#
POST/api/payments
Creates a payment and returns it with a checkoutUrl. Redirect your customer there. You need an active Telebirr or CBE Birr payout account first.
Body parameters
- amountstringRequired
- Positive ETB amount with up to two decimal places, such as
"850"or"850.50". - currencystring
- Only
ETBis supported. Defaults toETB. - descriptionstring
- Up to 160 characters, shown to the customer.
- externalReferencestring
- Your order ID, up to 128 characters. Returned in webhooks.
Headers
- AuthorizationstringRequired
- Bearer
mkt_…secret key. - Idempotency-Keystring
- Makes retries safe. See Idempotency.
Unknown fields are rejected with 400.
curl https://merkato-dashboard.kalebalebachew4.workers.dev/api/payments \
-H "Authorization: Bearer $MERKATO_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1042" \
-d '{
"amount": "850.00",
"description": "2 × Yirgacheffe, 250g",
"externalReference": "order_1042"
}'{
"orderId": "4f9c2a1e-8b7d-4c3a-9e21-6d0f5b8a7c13",
"reference": "mkt_9f86d081884c7d659a2feaa0c55ad015",
"externalReference": "order_1042",
"paymentLinkId": null,
"description": "2 × Yirgacheffe, 250g",
"amount": "850.00",
"currency": "ETB",
"status": "PENDING",
"checkoutUrl": "https://merkato-checkout.kalebalebachew4.workers.dev/pay/mkt_9f86…",
"createdAt": "2026-10-29T18:00:00.000Z",
"expiresAt": "2026-10-29T18:30:00.000Z",
"verifiedAt": null
}Retrieve a payment#
GET/api/payments/:reference
Returns a payment by its reference. Once paid, the response also includes the provider receipt.
Additional attributes
- transactionIdstring | null
- The provider receipt number, for reconciliation.
- settledAmountstring | null
- The amount on the verified receipt. Can exceed the order amount.
curl https://merkato-dashboard.kalebalebachew4.workers.dev/api/payments/mkt_9f86d081884c7d659a2feaa0c55ad015 \
-H "Authorization: Bearer $MERKATO_SECRET_KEY"{
"orderId": "4f9c2a1e-8b7d-4c3a-9e21-6d0f5b8a7c13",
"reference": "mkt_9f86d081884c7d659a2feaa0c55ad015",
"amount": "850.00",
"currency": "ETB",
"status": "PAID",
"verifiedAt": "2026-10-29T18:04:12.000Z",
"transactionId": "FT26301ABCD7",
"settledAmount": "850.00",
...
}Checkout
Hosted checkout#
Every payment has a checkoutUrl on Merkato's hosted page. It shows the amount and your payout account, then asks the customer for their receipt after they pay in their wallet app.
- Checkout stays open for 30 minutes. After that the payment becomes EXPIRED.
- A receipt can only settle one payment, and it must be paid to your account.
- Underpayments are rejected. Overpayments settle, and settledAmount shows what was paid.
Payment links#
Payment links are reusable checkout pages you create in the dashboard, with no code. Use one with a fixed amount for a product, or an open amount for donations and invoices. Each link also has a printable QR code.
Each time someone opens a link, Merkato creates a normal payment. It goes through the same webhooks and API, with paymentLinkId set so you can tell where it came from.
Webhooks
Events#
Set an HTTPS endpoint and copy its signing secret in your dashboard settings. Merkato sends a POST with a JSON body when a payment is verified.
Event types
- payment.confirmedevent
- The receipt was verified and the payment is now PAID. Fulfil the order.
Headers
- x-merkato-signaturestring
t=<unix seconds>,v1=<hex>. HMAC-SHA256 of the timestamp and raw body.- x-merkato-event-idstring
- Stable across retries. Store it to ignore duplicate deliveries.
POST /webhooks/merkato HTTP/1.1
content-type: application/json
x-merkato-event-id: 0b7e5c4a-2d1f-4e8b-a3c6-91f2d8e4b7a0
x-merkato-signature: t=1793311452,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdVerify signatures#
Check every webhook before you trust it:
- 1Read
tandv1from thex-merkato-signatureheader. - 2Build the signed string
{t}.{raw body}using the exact bytes you received. - 3Compute HMAC-SHA256 with your webhook secret and compare it to
v1in constant time. - 4Reject the request if
tis more than five minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyMerkato(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return false;
}
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
// Express: use the raw body, not the parsed JSON
app.post("/webhooks/merkato", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyMerkato(
req.body.toString("utf8"),
req.get("x-merkato-signature") ?? "",
process.env.MERKATO_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);
// Fulfil the order, keyed on x-merkato-event-id
res.sendStatus(200);
});Delivery and retries#
Return any 2xx status within a few seconds to acknowledge an event. Do slow work after you respond.
Anything else, including timeouts and redirects, counts as a failure. Merkato retries with exponential backoff, from a few seconds up to one hour between attempts. Every attempt is visible in your dashboard.
x-merkato-event-id or the payment reference.