Receiving webhooks
Your webhook receives the outcome of every payment, payout, transfer and exchange, whichever channel started it:
this API, the merchant portal, a batch file or a legacy key. Every intermediate status is in
GET /events as well.
Setting it up
Set the webhook in the merchant portal's webhook settings. You get one address for every event, and a signing
secret (whsec_…) that you can rotate there. A request never names its own notification address.
If you also run a legacy (/v1, JetonBank) integration, its notification address stays separate. The legacy address
keeps receiving legacy notifications for operations your legacy keys start, and the v2 webhook receives these
events for every operation.
What a delivery looks like
Each delivery is a POST of one Event with the three headers the
Standard Webhooks specification defines, so any of
its libraries verifies a delivery for you:
| Header | Value |
|---|---|
webhook-id |
The event id. A retried delivery carries the same id. |
webhook-timestamp |
Unix time in seconds when the delivery was signed. |
webhook-signature |
v1, followed by base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{rawBody}")), where the key is the bytes your secret encodes: the base64 after whsec_. |
The webhook receives these events: payment.completed|expired|cancelled|refunded,
payout.completed|failed|cancelled, transfer.completed|failed and
fx_exchange.completed|failed|cancelled.
A payment has no failed outcome. When an attempt fails, the customer can retry until the checkout expires, so the
payment stays pending; a payment never paid ends with payment.expired. A customer who committed just before the
deadline can still complete it after that: the money has then moved, so handle payment.completed even for an order
you closed on payment.expired.
Verifying a delivery (Node.js 18+)
Verify the signature over the raw body, before you parse it. A Standard Webhooks library does all of this; without one, it takes a few lines:
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
// While you rotate, keep both secrets: a delivery verifies with one of them.
const secrets = [process.env.JETON_WEBHOOK_SECRET, process.env.JETON_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
const keyOf = (secret) => Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
function verify(req) {
const id = req.get('webhook-id') ?? '';
const timestamp = req.get('webhook-timestamp') ?? '';
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), req.body]);
const given = (req.get('webhook-signature') ?? '')
.split(' ')
.filter((entry) => entry.startsWith('v1,'))
.map((entry) => Buffer.from(entry.slice(3), 'base64'));
return secrets.some((secret) => {
const expected = createHmac('sha256', keyOf(secret)).update(signed).digest();
return given.some((signature) => signature.length === expected.length && timingSafeEqual(signature, expected));
});
}
function handle(event) {
// Your processing: skip an event.id you have already handled.
}
const app = express();
app.post('/jeton/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge first, then work: answer 2xx within 10 seconds.
res.sendStatus(200);
handle(event); // deduplicate on event.id; see below
});
app.listen(3000);
Rotating the secret in the portal replaces it at once: every delivery from then on, retries included, is signed with the new one. Keep the old secret until the new one is deployed, and accept a delivery that verifies with either.
Delivery rules
- Answer
2xxwithin 10 seconds. Anything else is retried: another status, no answer, or an address that does not resolve or resolves to a private one. - Retries. A delivery is retried 12 times over about three and a half days: immediately, then after 3 s, 5 s,
15 s, 30 s, 1 m, 5 m, 15 m, 30 m, 1 h, 6 h, 24 h and 48 h. After the last attempt it is
exhausted, and the portal shows the webhook as failing until a later delivery succeeds. The event stays readable inGET /events. - At least once, in no fixed order. Deduplicate on the event
id. For the samedata.id, ignore a snapshot whosedata.updatedAtis older than the last one you processed:updatedAtmoves forward with every change, to the millisecond. When in doubt,GETthe resource. datais the full resource, exactly asGETreturned it when the event happened.- Changes apply to pending deliveries. Each attempt reads the address and the secret again, so a change in the portal reaches deliveries that are still being retried.
- Switched off, nothing is lost. While the webhook is switched off it receives nothing. Events raised meanwhile are delivered once you switch it back on, as long as they are within the event log's 30 days.