Developers
Webhooks
A webhook tells you an order changed state. It does not replace reading the order: it is a notification, not a source of truth.
Published contract, not open yet
Webhooks are not emitted today. Their shape, signature and retry policy are settled and published here so your receiver can be written and tested ahead of time.
Events
Every event carries the same envelope: an id, a type, a timestamp and a data object. Subscribe to the types you handle and silently ignore the rest — new types will be added, and a receiver that fails on an unknown type breaks itself.
| Type | What it means |
|---|---|
order.created | The order is created and the rate locked. The deposit address is assigned. |
order.deposit_detected | An incoming transaction is seen on the network, before confirmation. Use this event to reassure the user, never to deliver anything. |
order.confirming | The confirmation counter is progressing. Emitted at each step, not at each block. |
order.deposit_confirmed | The confirmation count required by the network is reached. |
order.underpaid | The amount received is below the expected amount beyond tolerance. The order is not lost: it awaits a choice between topping up, continuing at the received amount, or a refund. |
order.overpaid | The amount received exceeds the expected amount. The initial amount stays at the locked rate; the excess is handled separately. |
order.payout_sent | The transfer has left on the rail. Careful: sent is not received — the remaining delay depends on the beneficiary’s bank. |
order.completed | Settlement is confirmed by the rail. Terminal state. |
order.payout_failed | The rail rejected or returned the transfer, with its reason. No automatic retry is made on a bank return: the cause is nearly always in the details. |
order.refunded | Funds have been returned, only to the originating address, net of network fees. |
The payload
A constant envelope, whatever the type.
{
"id": "evt_01J9ZQF3K8N2M4X7",
"type": "order.payout_sent",
"created": 1788356981,
"data": {
"reference": "K7Q4-M2XB",
"state": "PAYOUT_SENT",
"railId": "sepa_instant",
"netMinor": 91228,
"currency": "EUR",
"sentAt": "2026-09-02T12:21:44Z"
}
}Signature
Every delivery carries a signature header: a timestamp and an HMAC-SHA256 computed over that timestamp, a dot, and the RAW request body.
Fiatside-Signature: t=<timestamp unix>,v1=<hex hmac-sha256>import { createHmac, timingSafeEqual } from 'node:crypto'
// IMPORTANT : le corps doit etre le corps BRUT, avant tout parsing JSON.
// Re-serialiser l'objet change l'ordre des cles et invalide la signature.
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]))
const timestamp = Number(parts['t'])
const signature = parts['v1']
if (!timestamp || !signature) return false
// Rejeu : une signature valide capturee hier ne doit pas etre rejouable aujourd'hui.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
// Comparaison a temps constant : un === laisse fuiter la signature octet par octet.
return a.length === b.length && timingSafeEqual(a, b)
}- Sign the raw body, before any parsing. Re-serialising the JSON object changes key order and whitespace: the signature will no longer match, and you will hunt for a long time.
- Compare in constant time. A === comparison stops at the first differing byte, which lets an attacker measure the expected signature byte by byte.
- Reject beyond the 300-second tolerance window. Without that check, a valid request captured yesterday stays replayable today.
- Answer 200 before processing. Put the event in your queue and handle it afterwards: long processing triggers a timeout and therefore a retry, even though you did receive the event.
Retries and ordering
Any non-2xx status, or no response at all, triggers a retry on the schedule below.
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 30 s |
| 3 | 2 min |
| 4 | 10 min |
| 5 | 1 h |
| 6 | 6 h |
- Arrival order is not guaranteed. A payout event can arrive before the confirmation event that logically precedes it: trust the order state, not the order of receipt.
- The same event can arrive twice. Store the event id and ignore an id already handled: idempotency on your receiver is your responsibility, and it is easy to get.
- Never trust payload amounts to credit anything on your side. Re-read the order through the API: the payload says something happened, the API says exactly what.
- After the last attempt the event is dropped and remains visible in your event log. You can replay it manually.