Skip to content

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.

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, 204 from the test receiver, in the console’s « Journal de livraison » or with GET /v1/webhooks/deliveries (deliveries): a delivery it answered is DELIVERED;
  • 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.

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
}

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;
}
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.

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.

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.