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 POST https://connect.tchokopay.com/v1/webhooks \
-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/webhooks 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",
"failureReason": null,
"timestamp": "2026-07-30T09:04:12.000Z"
}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/payments/: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: payment.succeededX-TchokoPay-Event is payment.succeeded or payment.failed.
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/webhooks (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/payments/:reference always works — poll it.
