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 2xx within 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 in GET /events.
  • At least once, in no fixed order. Deduplicate on the event id. For the same data.id, ignore a snapshot whose data.updatedAt is older than the last one you processed: updatedAt moves forward with every change, to the millisecond. When in doubt, GET the resource.
  • data is the full resource, exactly as GET returned 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.