Webhooks
Order and account events, HMAC-SHA256 signature, retries.
A webhook is a POST to your Project's URL on every event. The endpoint and the signing secret are configured in the dashboard; the secret is shown only once.
Events
type | When |
|---|---|
order.succeeded | the order was fulfilled |
order.failed | the order was not fulfilled, the balance hold was released |
order.needs_review | the outcome is unknown, support is checking the order |
supplier_account.health_changed | the health of your Supplier Account changed (OWN mode) |
Format
POST /ifragment/webhook HTTP/1.1
Content-Type: application/json
X-IFragment-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "0199a1b2-7c3d-7e4f-8a9b-0c1d2e3f4a5b",
"type": "order.succeeded",
"createdAt": "2026-09-26T10:15:30.000Z",
"data": {
"id": "0199a1b2-6f00-7abc-9def-112233445566",
"externalId": "1042",
"product": "STARS",
"quantity": 500,
"months": null,
"username": "durov",
"recipientName": "Pavel Durov",
"status": "SUCCEEDED",
"price": "8.25",
"currency": "USD",
"failure": null,
"createdAt": "2026-09-26T10:15:02.000Z",
"sentAt": "2026-09-26T10:15:05.000Z",
"completedAt": "2026-09-26T10:15:30.000Z"
}
}For supplier_account.health_changed, data contains the account:
{
"accountId": "0199a1b2-1111-7abc-9def-112233445566",
"label": "Main wallet",
"previousHealth": "HEALTHY",
"health": "DEGRADED",
"reasons": ["LOW_BALANCE"],
"changedAt": "2026-09-26T10:20:00.000Z"
}idis the event id, and it stays the same across redeliveries. Store processedids and skip duplicates: delivery is "at least once".dataholds the order object (as inGET /v1/orders/{id}) or the account forsupplier_account.health_changed. Fororder.failed, the reason is indata.failure.code.- Events may arrive out of order; rely on
data.status, and when in doubt, fetch the order through the API.
Response
Reply with any 2xx within 10 seconds; the response body is ignored. Do long-running work after replying (a queue, a background job).
Retries
On a non-2xx response, a timeout or a connection error, delivery is retried with exponential backoff: 12 attempts over about 24 hours in total. After that the delivery is marked FAILED; you can retry it manually from the dashboard.
Verifying the signature
X-IFragment-Signature: t=<unix>,v1=<hex>, where v1 = hex(HMAC-SHA256(secret, "<t>.<rawBody>")):
- split the header into
tandv1; - compute HMAC-SHA256 over the string
t + "." + raw request body, that is, the bytes as received, before JSON parsing; - compare it with
v1in constant time; - reject the request if
tdiffers from the current time by more than 5 minutes (replay protection).
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyIFragmentSignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2) as [string, string]));
const timestamp = Number(parts.t);
if (!parts.v1 || !Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) {
return false;
}
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
// Express: take the raw body, not the parsed JSON.
app.post('/ifragment/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyIFragmentSignature(raw, req.get('X-IFragment-Signature') ?? '', process.env.IFRAGMENT_WEBHOOK_SECRET!)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
res.sendStatus(200);
queue.add(event); // process after replying
});A common mistake is computing the signature over re-serialized JSON (JSON.stringify(req.body)). Field order and whitespace may differ, and the signature will not match. Always use the raw body.