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

    Create. Your server creates a payment for the order total.

  2. 2

    Redirect. The customer opens checkoutUrl and pays from their wallet.

  3. 3

    Verify. Merkato checks the receipt with the provider.

  4. 4

    Fulfil. You receive a signed payment.confirmed webhook.

Base URL
https://merkato-dashboard.kalebalebachew4.workers.dev

Supported wallets

TelebirrCBE Birr

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.

Keep secret keys on your server
Never put a key in a mobile app, browser code, or a public repository. If one leaks, revoke it in the dashboard and create a new one.
cURL
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.

200OKThe request succeeded, or an idempotent request was replayed.
201CreatedA new payment was created.
400Bad requestThe body, header, or reference is malformed.
401UnauthorizedThe API key is missing, malformed, or revoked.
404Not foundNo payment with that reference belongs to your account.
409ConflictAn Idempotency-Key was reused with a different body.
422UnprocessableNo active Telebirr or CBE Birr payout account is set up.
429Too many requestsYou hit a rate limit. Back off and retry.
503UnavailableA temporary failure. Retry with backoff.
JSON
// 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
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: true

Rate limits#

Limits apply per merchant account over a rolling one-minute window.

60CreatePOST /api/payments
120RetrieveGET /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
PENDING until paid, PAID once the receipt is verified, or EXPIRED if 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.
The payment object
JSON
{
  "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 ETB is supported. Defaults to ETB.
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.

POST
/api/payments
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"
  }'
Response
201 Created
{
  "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.
GET
/api/payments/:reference
curl https://merkato-dashboard.kalebalebachew4.workers.dev/api/payments/mkt_9f86d081884c7d659a2feaa0c55ad015 \
  -H "Authorization: Bearer $MERKATO_SECRET_KEY"
Response
200 OK
{
  "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.

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.
payment.confirmed
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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verify signatures#

Check every webhook before you trust it:

  1. 1Read t and v1 from the x-merkato-signature header.
  2. 2Build the signed string {t}.{raw body} using the exact bytes you received.
  3. 3Compute HMAC-SHA256 with your webhook secret and compare it to v1 in constant time.
  4. 4Reject the request if t is more than five minutes from your clock.
Use the raw body
Parsing and re-serializing JSON changes the bytes and breaks the signature. Verify first, then parse.
Verify a webhook
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.

Handle duplicates
The same event can arrive more than once. Make your handler idempotent by recording the x-merkato-event-id or the payment reference.