Webhooks
Futa informe votre serveur des paiements, remboursements, encaissements et versements par des requêtes POST signées. Un
commerce a un point de réception. Dans le bac à sable, il est sur 127.0.0.1, si bien qu’aucune livraison ne quitte
le bac à sable : le port 18160 est le récepteur propre au service des canaux, pris seulement à /sandbox/webhooks,
et sur votre machine votre récepteur écoute sur un port de 18161 à 18169 à son propre chemin, d’au plus 512
caractères, avec une chaîne de requête facultative d’au plus 512. Dans le bac à sable hébergé, seuls les récepteurs
propres à Futa écoutent, si bien qu’aucune livraison n’en atteint votre serveur. Une livraison ne suit jamais de redirection.
Répondez n’importe quel 2xx pour accuser réception d’une livraison.
Dans le bac à sable hébergé
Section intitulée « Dans le bac à sable hébergé »Le bac à sable hébergé ne livre que chez Futa, jamais à votre serveur. Son récepteur de test,
http://127.0.0.1:18161/sandbox/webhooks, répond 204 à chaque livraison dont la Futa-Signature est bien formée et
récente, sans la vérifier avec votre secret, et ne garde rien. http://127.0.0.1:18160/sandbox/webhooks n’accepte que
les livraisons du commerce du bac à sable propre à Futa, signées avec le secret de ce commerce, et répond 401 aux
autres. Un commerce de test créé dans le portail marchand démarre sans point de réception. Pour y suivre vos paiements
de test :
- ajoutez le point de réception dans la page Webhooks de la console développeur : « Ajouter un point de
terminaison », puis
http://127.0.0.1:18161/sandbox/webhooks, le « Récepteur de test 2 du sandbox » de la console, et non le premier récepteur qu’elle propose ; le troisième n’a aucun auditeur dans le bac à sable hébergé ; - lisez chaque livraison, avec son événement, ses tentatives et le statut HTTP que chacune a reçu,
204du récepteur de test, dans le « Journal de livraison » de la console ou avecGET /v1/webhooks/deliveries(livraisons) : une livraison à laquelle il a répondu estDELIVERED; - confirmez chaque paiement avec
GET /v1/payments/{id}, son état qui fait autorité ; - vérifiez votre code de signature avec les vecteurs signés, et recevez les livraisons sur votre propre serveur en faisant tourner le bac à sable en local : le récepteur de test ne vérifie aucune signature, il ne prouve donc rien de votre vérification.
Les livraisons vers vos propres serveurs attendent la vérification des destinations et le contrôle des sorties.
Le point de réception
Section intitulée « Le point de réception »Sur votre machine, le commerce du bac à sable démarre avec son point de réception enregistré à
http://127.0.0.1:18160/sandbox/webhooks, signé avec le WEBHOOK_SECRET de votre sandbox/.env.
GET /v1/webhooks/endpoint avec webhooks:read lit la configuration sans le secret : son url, ses events et sa
config_version ; un commerce sans point de réception reçoit 404 WEBHOOK_NOT_FOUND.
Un commerce sans point de réception l’enregistre avec POST /v1/webhooks/endpoint et webhooks:manage. Un commerce
qui en a un, celui du bac à sable compris, reçoit 409 WEBHOOK_ALREADY_CONFIGURED : il déplace alors son point de
réception avec POST /v1/webhooks/endpoint/configuration (voir choisir les événements).
La destination ci-dessous est votre propre récepteur sur votre machine, et le récepteur de test du bac à sable dans
le bac à sable hébergé.
{ "url": "http://127.0.0.1:18161/sandbox/webhooks"}L’enregistrement révèle une seule fois le secret de signature, avec sa version ; POST /v1/webhooks/endpoint/rotate
aussi, qui le remplace : l’ancien secret signe encore pendant une heure, si bien qu’une livraison peut porter deux
signatures dans l’intervalle, et un renouvellement suivant remplace ce chevauchement. Gardez le secret sur votre
serveur.
{ "secret": "whsec_test_example-shown-once", "version": 2}Vérifier la signature
Section intitulée « Vérifier la signature »Chaque livraison porte Futa-Signature: t=<epoch>,v1=<hex>, où t compte les secondes depuis l’epoch, avec un v1
de plus pendant le chevauchement d’un renouvellement. Refusez d’abord un horodatage à plus de cinq minutes de votre
horloge, comme les récepteurs de Futa : chaque tentative est signée à nouveau, si bien qu’une vraie livraison est
toujours récente. Calculez ensuite un HMAC-SHA256 avec le secret sur l’horodatage, un point et le corps brut de la
requête, tel que reçu, et acceptez la livraison quand un v1 correspond, comparé en temps constant. Ne lisez le JSON
qu’ensuite, et répondez 400 à une livraison que la vérification refuse, en-tête absent compris.
L’en-tête est le paramètre Futa-Signature de chaque webhook du contrat : t=, puis une signature v1= de 64
chiffres hexadécimaux en minuscules, ou deux pendant le chevauchement d’un renouvellement, celle du secret actuel
d’abord. webhook-signature.vectors.json contient des événements du bac à sable
signés, avec leur en-tête et leurs secrets : votre vérification accepte chacune, votre horloge réglée sur son
timestamp.
import crypto from "node:crypto";
// Les récepteurs de Futa refusent un horodatage à plus de cinq minutes de leur horloge : chaque tentative est signée à nouveau.const TOLERANCE_SECONDS = 300;
// header : Futa-Signature, peut-être absent ; rawBody : le corps tel que reçu, avant toute lecture du JSON.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// Les récepteurs de Futa refusent un horodatage à plus de cinq minutes de leur horloge : chaque tentative est signée à nouveau.const TOLERANCE_SECONDS = 300;
// $header : Futa-Signature, peut-être absent ; $rawBody : le corps tel que reçu, avant toute lecture du JSON.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
# Les récepteurs de Futa refusent un horodatage à plus de cinq minutes de leur horloge : chaque tentative est signée à nouveau.TOLERANCE_SECONDS = 300
# header : Futa-Signature, peut-être absent ; raw_body : le corps tel que reçu, avant toute lecture du JSON.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 NoneLes événements
Section intitulée « Les événements »| Événement | Envoyé par défaut | Quand |
|---|---|---|
payment.created |
Oui | Un paiement a été créé. |
payment.pending_approval |
Oui | L’approbation du payeur est attendue. |
payment.uncertain |
Oui | L’émetteur n’a pas répondu : attendez l’événement final. |
payment.approved |
Oui | Le paiement est approuvé : livrez la commande. |
payment.declined |
Oui | Le paiement a été refusé. |
payment.expired |
Oui | Le paiement a expiré avant d’être approuvé. |
refund.pending_approval |
Non | Un remboursement au-delà du seuil du commerce attend qu’une autre personne que son demandeur l’approuve. |
refund.queued |
Oui | Un remboursement est en file d’attente. |
refund.processing |
Oui | Un remboursement est en cours. |
refund.uncertain |
Oui | Le résultat d’un remboursement n’est pas encore connu. |
refund.completed |
Oui | Un remboursement est effectué. |
refund.failed |
Oui | Un remboursement a échoué. |
refund.expired |
Non | Un remboursement n’a pas été approuvé dans les sept jours. |
refund.rejected |
Non | Un remboursement a été rejeté par la personne chargée de l’approuver. |
obligation.collected |
Non | Une obligation de paiement a été encaissée ; voir les webhooks d’encaissement. |
payout.pending_approval |
Non | Un versement attend qu’une deuxième personne l’approuve dans le portail. |
payout.completed |
Oui | Un versement est payé. |
payout.failed |
Oui | Un versement a échoué, avec son motif. |
payout.rejected |
Non | Un versement a été rejeté par la personne chargée de l’approuver. |
payout.expired |
Non | Un versement n’a pas été approuvé dans les sept jours. |
payout_batch.validated |
Non | Les lignes d’un lot de versements sont vérifiées, prêtes pour les décisions de son préparateur. |
payout_batch.completed |
Oui | Chaque ligne d’un lot de versements est définitive. |
Un événement de paiement suit 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 }}Le même événement peut arriver plusieurs fois : dédupliquez sur son id. Ordonnez les événements d’un paiement par
data.sequence, pas par ordre d’arrivée, et lisez GET /v1/payments/{id} en cas de doute : le paiement fait
autorité.
Un événement de remboursement suit refund-event.schema.json. data.payment_id désigne
le paiement remboursé, y compris pour un remboursement demandé dans le portail :
{ "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": "Article retourné, commande 10482", "created_at": "2026-10-20T09:58:12.204117Z", "issuer_reference": "synthetic-refund-1", "sequence": 2, "escalated": false, "sandbox": true, "approval": null }}N’appliquez l’événement d’un remboursement que si son data.sequence dépasse le dernier appliqué pour ce
remboursement, et lisez GET /v1/refunds/{id} en cas de doute : le remboursement fait autorité.
payment-event.vectors.json et
refund-event.vectors.json contiennent des événements enregistrés de chaque type.
Un événement de versement suit payout-event.schema.json et un événement de lot
payout-batch-event.schema.json, avec leurs événements enregistrés dans
payout-event.vectors.json et
payout-batch-event.vectors.json. Les états QUEUED, PROCESSING et
UNCERTAIN d’un versement n’envoient aucun événement, et un événement ne porte que key_holders.known, jamais qui
détient une clé. Dédupliquez sur l’id de l’événement, n’appliquez l’événement d’un versement que si son
data.version dépasse le dernier appliqué pour ce versement, et lisez le versement ou le lot en cas de doute ; le
guide des versements montre un événement de versement.
Choisir les événements
Section intitulée « Choisir les événements »POST /v1/webhooks/endpoint/configuration avec webhooks:manage et une Idempotency-Key fixe la destination et les
événements à envoyer, à la version actuelle de la configuration. La destination ci-dessous est votre propre
récepteur sur votre machine, et le récepteur de test du bac à sable dans le bac à sable hébergé :
{ "url": "http://127.0.0.1:18161/sandbox/webhooks", "events": ["payment.approved", "payment.declined", "payment.expired", "refund.completed"], "version": 1}Lisez la version avec GET /v1/webhooks/endpoint ; une version périmée répond 409 WEBHOOK_CHANGED. Le choix
s’applique quand un événement est mis en file : les événements déjà en file gardent le leur, et une liste vide ne
sélectionne aucun nouvel événement. Tant que vous n’avez pas choisi, un nouveau point de réception reçoit les quatorze
événements que le tableau marque comme envoyés par défaut ; refund.pending_approval, refund.expired,
refund.rejected, obligation.collected, payout.pending_approval, payout.rejected, payout.expired et
payout_batch.validated n’arrivent qu’une fois choisis. Un point de réception enregistré avant que les événements de
versement soient livrés garde les événements qu’il avait : ajoutez-lui les événements de versement pour les recevoir.
Livraisons, relances et renvois
Section intitulée « Livraisons, relances et renvois »Une livraison qui échoue, ou qui n’obtient pas de 2xx en trois secondes, est relancée après des pauses croissantes,
jusqu’à une heure d’écart, pendant 72 heures. GET /v1/webhooks/deliveries avec webhooks:read liste les
livraisons, des plus récentes aux plus anciennes, page par page, chacune DELIVERED, NOT_SELECTED, EXPIRED,
PENDING, RETRYING ou HELD. GET /v1/webhooks/deliveries/{id}/attempts liste les tentatives d’un événement avec
le statut HTTP que chacune a reçu, 0 quand aucune réponse HTTP n’est venue.
Une entrée HELD est un événement que Futa n’a jamais envoyé : il est arrivé chez Futa sous une forme que le contrat
ne prévoit pas, ou nommait votre commerce avant qu’il existe ici. Elle ne porte pas de corps et nomme son paiement
dans payment_id quand il peut être lu, et dans payment_reference pour un événement de paiement, avec held_at ;
elle n’a ni prochaine tentative ni échéance, n’est jamais renvoyée et reste listée 30 jours. Lisez le paiement avec
GET /v1/payments/{id}.
POST /v1/webhooks/deliveries/{id}/replay avec webhooks:manage et une Idempotency-Key renvoie un événement sous
le même identifiant et rouvre sa fenêtre de 72 heures, au plus une fois par minute et par événement. Un événement hors de l’abonnement actuel
répond 409 EVENT_NOT_SUBSCRIBED.