Webhooks
Futa tells your server about payments, refunds, collections and payouts with signed POST requests. A merchant has one
endpoint. In the sandbox it is on 127.0.0.1, so no delivery leaves the sandbox: port 18160 is the channels service’s
own sink, taken only at /sandbox/webhooks, and on your machine your receiver listens on a port from 18161 to 18169
at its own path, at most 512 characters, with an optional query of at most 512. In the hosted sandbox only Futa’s own
receivers listen, so no delivery reaches your server from it. A delivery never follows a redirect. Answer any 2xx to
acknowledge a delivery.
In the hosted sandbox
Section titled “In the hosted sandbox”The hosted sandbox delivers only inside Futa, never to your server. Its test receiver,
http://127.0.0.1:18161/sandbox/webhooks, answers 204 to every delivery whose Futa-Signature is well formed and
fresh, without checking it against your secret, and keeps nothing. http://127.0.0.1:18160/sandbox/webhooks accepts
only the deliveries of Futa’s own sandbox merchant, signed with that merchant’s secret, and answers others 401. A
test business you create in the merchant portal starts with no endpoint. To follow your test payments there:
- add the endpoint in the developer console’s Webhooks page: « Ajouter un point de terminaison »,
then
http://127.0.0.1:18161/sandbox/webhooks, the console’s « Récepteur de test 2 du sandbox », not the first receiver it offers; the third has no listener when hosted; - read each delivery, with its event, its attempts and the HTTP status each received,
204from the test receiver, in the console’s « Journal de livraison » or withGET /v1/webhooks/deliveries(deliveries): a delivery it answered isDELIVERED; - confirm each payment with
GET /v1/payments/{id}, its authoritative state; - check your signature code against the signed vectors, and receive deliveries on your own server by running the sandbox locally: the test receiver checks no signature, so it proves nothing of your verification.
Deliveries to your own servers wait for destination checks and egress controls.
The endpoint
Section titled “The endpoint”On your machine, the sandbox merchant starts with its endpoint registered at
http://127.0.0.1:18160/sandbox/webhooks, signed with WEBHOOK_SECRET from your sandbox/.env.
GET /v1/webhooks/endpoint with webhooks:read reads the configuration without the secret: its url, events and
config_version; a merchant without an endpoint gets 404 WEBHOOK_NOT_FOUND.
A merchant without an endpoint registers one with POST /v1/webhooks/endpoint and webhooks:manage. A merchant
that has one, the sandbox merchant included, gets 409 WEBHOOK_ALREADY_CONFIGURED: it moves its endpoint with
POST /v1/webhooks/endpoint/configuration instead (see choose the events). The destination
below is your own receiver on your machine, and the sandbox’s test receiver in the hosted sandbox.
{ "url": "http://127.0.0.1:18161/sandbox/webhooks"}Registering reveals the signing secret once, with its version; so does POST /v1/webhooks/endpoint/rotate,
which replaces it: the previous secret keeps signing for one hour, so a delivery can carry two signatures
meanwhile, and a later rotation replaces that overlap. Keep the secret on your server.
{ "secret": "whsec_test_example-shown-once", "version": 2}Verify the signature
Section titled “Verify the signature”Each delivery carries Futa-Signature: t=<epoch>,v1=<hex>, where t counts seconds since the epoch, with one more
v1 during a rotation’s overlap. First refuse a timestamp more than five minutes from your clock, as Futa’s own
receivers do: each attempt is signed anew, so a genuine delivery is always fresh. Then compute HMAC-SHA256 with the
secret over the timestamp, a dot and the raw request body, exactly as received, and accept the delivery when any v1
matches, compared in constant time. Parse the JSON only afterwards, and answer 400 to a delivery the check refuses,
a missing header included.
The header is the Futa-Signature parameter of each webhook in the contract: t=, then one v1= signature of 64
lower-case hexadecimal digits, or two during a rotation’s overlap, the current secret’s first.
webhook-signature.vectors.json holds signed sandbox events with their header and
secrets: your verification accepts each one with your clock at its timestamp.
import crypto from "node:crypto";
// Futa's receivers refuse a timestamp more than five minutes from their clock: each attempt is signed anew.const TOLERANCE_SECONDS = 300;
// header: Futa-Signature, possibly missing; rawBody: the body as received, before any JSON parsing.export function verifiedEvent(header, rawBody, secret) { const fields = String(header ?? "").split(","); const timestamp = fields.find((field) => field.startsWith("t="))?.slice(2) ?? ""; if (!/^\d+$/.test(timestamp)) return null; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return null; const expected = Buffer.from( crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"), ); const valid = fields .filter((field) => field.startsWith("v1=")) .some((field) => { const signature = Buffer.from(field.slice(3)); return signature.length === expected.length && crypto.timingSafeEqual(signature, expected); }); return valid ? JSON.parse(rawBody) : null;}<?php// Futa's receivers refuse a timestamp more than five minutes from their clock: each attempt is signed anew.const TOLERANCE_SECONDS = 300;
// $header: Futa-Signature, possibly missing; $rawBody: the body as received, before any JSON parsing.function verified_event(?string $header, string $rawBody, string $secret): ?array{ $fields = explode(',', $header ?? ''); $timestamp = ''; foreach ($fields as $field) { if (str_starts_with($field, 't=')) { $timestamp = substr($field, 2); } } if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) { return null; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($fields as $field) { if (str_starts_with($field, 'v1=') && hash_equals($expected, substr($field, 3))) { return json_decode($rawBody, true); } } return null;}import hashlibimport hmacimport jsonimport time
# Futa's receivers refuse a timestamp more than five minutes from their clock: each attempt is signed anew.TOLERANCE_SECONDS = 300
# header: Futa-Signature, possibly missing; raw_body: the body as received, before any JSON parsing.def verified_event(header, raw_body: bytes, secret: str): fields = (header or "").split(",") timestamp = next((field[2:] for field in fields if field.startswith("t=")), "") if not (timestamp.isascii() and timestamp.isdigit()): return None if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return None expected = hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest().encode() if any( hmac.compare_digest(field[3:].encode(), expected) for field in fields if field.startswith("v1=") ): return json.loads(raw_body) return NoneThe events
Section titled “The events”| Event | Sent by default | When |
|---|---|---|
payment.created |
Yes | A payment was created. |
payment.pending_approval |
Yes | The payer’s approval is awaited. |
payment.uncertain |
Yes | The issuer did not answer: wait for the final event. |
payment.approved |
Yes | The payment is approved: fulfil the order. |
payment.declined |
Yes | The payment was declined. |
payment.expired |
Yes | The payment expired before approval. |
refund.pending_approval |
No | A refund above the merchant’s threshold waits for a person other than its requester to approve it. |
refund.queued |
Yes | A refund is queued. |
refund.processing |
Yes | A refund is under way. |
refund.uncertain |
Yes | A refund’s outcome is not known yet. |
refund.completed |
Yes | A refund is completed. |
refund.failed |
Yes | A refund failed. |
refund.expired |
No | A refund was not approved within seven days. |
refund.rejected |
No | A refund was rejected by the approver. |
obligation.collected |
No | A payment obligation was collected; see collection webhooks. |
payout.pending_approval |
No | A payout waits for a second person to approve it in the portal. |
payout.completed |
Yes | A payout is paid. |
payout.failed |
Yes | A payout failed, with its reason. |
payout.rejected |
No | A payout was rejected by the approver. |
payout.expired |
No | A payout was not approved within seven days. |
payout_batch.validated |
No | A payout batch’s lines are checked, ready for the preparer’s decisions. |
payout_batch.completed |
Yes | Every line of a payout batch is final. |
A payment event follows payment-event.schema.json:
{ "id": "0b8e7d0e-5b0f-4a39-9d55-6f1c2e4a7b31", "type": "payment.approved", "occurred_at": "2026-10-20T09:14:05.123456Z", "schema_version": 1, "data": { "id": "5202ca3f-3215-42ad-8880-2de1f57ad097", "reference": "FT-5202CA3F321542AD88802DE1F57AD097", "merchant_id": "sandbox-merchant", "amount": 2500000, "currency": "CDF", "state": "APPROVED", "reason": null, "sequence": 3, "sandbox": true }}The same event can arrive more than once: deduplicate on its id. Order a payment’s events by data.sequence, not
by arrival, and read GET /v1/payments/{id} when in doubt: the payment is authoritative.
A refund event follows refund-event.schema.json. data.payment_id names the refunded
payment, for a refund asked for in the portal too:
{ "id": "7d3c1a52-90b4-4c8e-9a51-3e2f6b0d4c18", "type": "refund.completed", "occurred_at": "2026-10-20T10:02:41.512903Z", "schema_version": 1, "data": { "id": "c41e9f07-2b6d-4a3e-8f15-6d0a9b2e7c53", "payment_id": "5202ca3f-3215-42ad-8880-2de1f57ad097", "merchant_id": "sandbox-merchant", "amount": 1000000, "currency": "CDF", "state": "COMPLETED", "reason": "ISSUER_COMPLETED", "merchant_reason": "Returned item, order 10482", "created_at": "2026-10-20T09:58:12.204117Z", "issuer_reference": "synthetic-refund-1", "sequence": 2, "escalated": false, "sandbox": true, "approval": null }}Apply a refund’s event only when its data.sequence is above the last one applied for that refund, and read
GET /v1/refunds/{id} when in doubt: the refund is authoritative.
payment-event.vectors.json and
refund-event.vectors.json hold recorded events of every type.
A payout event follows payout-event.schema.json and a batch event
payout-batch-event.schema.json, with their recorded events in
payout-event.vectors.json and
payout-batch-event.vectors.json. A payout’s QUEUED, PROCESSING and
UNCERTAIN states send no event, and an event carries key_holders.known alone, never who holds a key. Deduplicate on
the event’s id, apply a payout’s event only when its data.version is above the last applied for that payout, and
read the payout or the batch when in doubt; the payouts guide shows a payout event.
Choose the events
Section titled “Choose the events”POST /v1/webhooks/endpoint/configuration with webhooks:manage and an Idempotency-Key sets the destination and
the events to send, at the configuration’s current version. The destination below is your own receiver on your
machine, and the sandbox’s test receiver in the hosted sandbox:
{ "url": "http://127.0.0.1:18161/sandbox/webhooks", "events": ["payment.approved", "payment.declined", "payment.expired", "refund.completed"], "version": 1}Read the version with GET /v1/webhooks/endpoint; a stale one answers 409 WEBHOOK_CHANGED. The choice applies
when an event is queued: events already queued keep theirs, and an empty list selects no new event. Until you choose,
a new endpoint receives the fourteen events the table marks as sent by default; refund.pending_approval,
refund.expired, refund.rejected, obligation.collected, payout.pending_approval, payout.rejected,
payout.expired and payout_batch.validated arrive only once chosen. An endpoint registered before payout events
were delivered keeps the events it had: add the payout events to it to receive them.
Deliveries, retries and replay
Section titled “Deliveries, retries and replay”A delivery that fails, or gets no 2xx within three seconds, is retried after growing pauses, up to an hour apart,
for 72 hours. GET /v1/webhooks/deliveries with webhooks:read lists deliveries newest first, a page at a time,
each DELIVERED, NOT_SELECTED, EXPIRED, PENDING, RETRYING or HELD.
GET /v1/webhooks/deliveries/{id}/attempts lists an event’s attempts with the HTTP status each received, 0 when no
HTTP answer came.
A HELD entry is an event Futa never sent: it reached Futa in a form the contract does not state, or named your
merchant before it existed here. It carries no body and names its payment in payment_id when it can be read, and in
payment_reference for a payment event, with held_at; it has no next attempt or expiry, is never replayed and
stays listed for 30 days. Read the payment with GET /v1/payments/{id}.
POST /v1/webhooks/deliveries/{id}/replay with webhooks:manage and an Idempotency-Key sends an event again under
the same ID and reopens its 72-hour window, at most once a minute per event. An event outside the current
subscription answers 409 EVENT_NOT_SUBSCRIBED.