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.succeededX-TchokoPay-Event names the event. There are five:
| Event | When |
|---|---|
collect.succeeded | A customer's payment cleared. |
collect.failed | A customer's payment didn't. |
disburse.sent | A disbursement was handed to the network and accepted. |
disburse.succeeded | A disbursement was confirmed delivered. |
disburse.failed | A 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.