Aller au contenu

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.

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, 204 du récepteur de test, dans le « Journal de livraison » de la console ou avec GET /v1/webhooks/deliveries (livraisons) : une livraison à laquelle il a répondu est DELIVERED ;
  • 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.

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
}

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

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.

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.