TchokoPay
Guides

Webhooks

Get notified the moment a payment resolves — no polling.

Register a URL once and TchokoPay will POST to it every time one of your payments reaches a terminal state, signed so you can verify it actually came from TchokoPay.

Registering an endpoint

curl -X PUT https://connect.tchokopay.com/v1/webhook \
  -H "Authorization: Bearer tchoko_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://mystore.example.com/webhooks/tchokopay" }'

The response includes your signing secret, shown once — save it:

{
  "url": "https://mystore.example.com/webhooks/tchokopay",
  "signingSecret": "whsec_a1b2c3d4e5f6...",
  "isActive": true,
  "updatedAt": "2026-07-30T09:00:00.000Z"
}

One endpoint per merchant account. Registering a new URL rotates the secret and clears any auto-disable state (see below). GET /v1/webhook returns your current endpoint's url, isActive, and disable status — never the secret again.

Your URL must be a real, publicly reachable http(s) address. Private/internal addresses (localhost, 169.254.x.x, RFC1918 ranges, etc.) are rejected at registration time, and re-checked again immediately before every single delivery.

What you receive

A signed POST when a payment reaches SUCCESS or FAILED:

{
  "invoiceReference": "REQ-1785296850838-A5303B",
  "status": "SUCCESS",
  "amount": 0.00088456,
  "currency": "BTC",
  "settledAmount": 0.00078347,
  "settledCurrency": "BTC",
  "requestedAmount": 50,
  "requestedCurrency": "USD",
  "paymentMethod": "LIGHTNING",
  "providerCode": "lightning",
  "failureReason": null,
  "timestamp": "2026-07-30T09:04:12.000Z"
}

paymentMethod vs. providerCode

paymentMethod is the class — MOBILE_MONEY for MTN, Orange and every other network alike — so it cannot tell you which one paid. providerCode can, and is the field to branch on. It is null until a payer has chosen.

amount vs. settledAmount

amount/currency is what the payer was actually charged (fee-inclusive). settledAmount/settledCurrency is what you actually received (net) — the same number GET /v1/collect/:reference returns as amount/currency. These are two different, both-correct numbers by design: the gap between them is the platform fee. Use whichever one answers the question you're actually asking.

Headers on every delivery:

X-TchokoPay-Signature: sha256=<hex hmac>
X-TchokoPay-Timestamp: 1785296852
X-TchokoPay-Event: collect.succeeded

X-TchokoPay-Event names the event. There are five:

EventWhen
collect.succeededA customer's payment cleared.
collect.failedA customer's payment didn't.
disburse.sentA disbursement was handed to the network and accepted.
disburse.succeededA disbursement was confirmed delivered.
disburse.failedA disbursement was refused by the network, or declined during review.

Disbursement events carry a different body from collection events:

{
  "event": "disburse.succeeded",
  "data": {
    "reference": "PO-1787962449863-7B361B",
    "status": "PAID",
    "amount": 25000,
    "currency": "XAF",
    "phone": "670000000",
    "recipientName": "Amina Njoya",
    "merchantReference": "supplier-invoice-4417",
    "providerReference": "MMH17797898332",
    "statusReason": null
  }
}

Everything else — the signature, the retries, the endpoint — is identical. One endpoint receives all five; branch on X-TchokoPay-Event.

disburse.sent is not disburse.succeeded. The first means the network accepted it, the second means the recipient has it. Do not tell anyone the money arrived on disburse.sent.

If you registered before August 2026

Endpoints created before these names existed still receive the old ones — payment.succeeded, payment.failed, payout.sent, payout.paid, payout.failed — and will keep receiving them. Your handler does not need changing.

Check which set you are on:

curl https://connect.tchokopay.com/v1/webhook \
  -H "Authorization: Bearer tchoko_live_..."
{ "url": "https://yourapp.com/webhooks/tchokopay", "isActive": true, "events": "legacy" }

To move, deploy a handler that reads the new names, then switch:

curl -X PUT https://connect.tchokopay.com/v1/webhook \
  -H "Authorization: Bearer tchoko_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/webhooks/tchokopay", "events": "v2" }'

Switching stops the old names arriving immediately, and nothing will raise an error if your handler still expects them — it will simply stop matching. Deploy the new handler first. Re-registering also issues a new signing secret, so update that at the same time.

Verifying the signature

const crypto = require('crypto');

function verifyWebhook(rawBody, signatureHeader, timestampHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest('hex');

  const provided = signatureHeader.replace('sha256=', '');
  const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));

  // Reject anything older than 5 minutes even with a valid signature —
  // protects against a captured delivery being replayed later.
  const age = Math.abs(Date.now() / 1000 - Number(timestampHeader));
  if (age > 300) return false;

  return valid;
}

app.post('/webhooks/tchokopay', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-tchokopay-signature'];
  const timestamp = req.headers['x-tchokopay-timestamp'];

  if (!verifyWebhook(req.body.toString(), signature, timestamp, process.env.TCHOKOPAY_WEBHOOK_SECRET)) {
    return res.status(400).send('invalid signature');
  }

  const event = JSON.parse(req.body.toString());
  if (event.status === 'SUCCESS') {
    markOrderPaid(event.invoiceReference, event.settledAmount, event.settledCurrency);
  }

  res.sendStatus(200);
});

Use the raw body

Compute the HMAC over the raw request body bytes you received, not a re-serialized copy. Re-parsing and re-stringifying JSON can reorder keys and produce a signature mismatch that has nothing to do with tampering — this is the single most common integration bug with webhook verification.

Retries and auto-disable

A failed delivery (your endpoint down, timing out, or returning a non-2xx) is retried automatically with backoff. If your endpoint fails 10 consecutive events with zero successes, it's automatically disabled so you stop losing retries into a dead URL — you'll see this on GET /v1/webhook (isActive: false, disabledReason set) and in your merchant dashboard, with a one-click reactivate that doesn't require rotating your secret.

If you'd rather not rely on webhooks at all, GET /v1/collect/:reference always works — poll it.

On this page