Fleeexdocs

Webhooks

Soyez prévenu avant que votre utilisateur ne soit coupé. Les trois événements, le schéma de signature HMAC détaillé assez pour l'implémenter partout, et le contrat de réessai.

Interroger getBalance() en boucle fait peser la charge sur vous et rate quand même le moment : la requête qui épuise le solde est celle qui échoue. Enregistrez plutôt un seul endpoint https pour votre application, et c'est Fleeex qui vous appelle.

Les événements

Trois types, et vous vous y abonnez explicitement. Un type que Fleeex publierait plus tard n'est jamais envoyé à un endpoint qui ne l'a pas demandé.

ÉvénementSe déclenche quanddata
balance.lowLe solde dépensable de votre utilisateur passe sous le seuil que vous avez fixé, une fois par franchissement et non à chaque requête en dessous.userId, availableMicros, thresholdMicros, currency
topup.completedVotre utilisateur a approvisionné son portefeuille, sur le paiement vérifié et jamais sur une redirection.userId, balanceMicros, availableMicros, currency
connection.createdL'un de vos utilisateurs a connecté son portefeuille à votre application pour la première fois.userId, connectedAt

balance.low surveille les fonds dépensables plutôt que le solde brut : ce qui compte, c'est ce que la prochaine requête peut réellement dépenser, et un gros flux en cours rend véritablement des fonds indisponibles. Conséquence à anticiper : ouvrir un gros flux peut franchir le seuil et le règlement le refranchir en sens inverse, ce qui fait deux notifications véridiques plutôt qu'une.

connection.created se déclenche sur une première autorisation. Un utilisateur qui se déconnecte puis se reconnecte ne le redéclenche pas. Et topup.completed ne se déclenche que sur de l'argent entrant : un remboursement ou une opposition bougent aussi le portefeuille, mais vous annoncer « votre utilisateur a rechargé » alors que sa carte a été débitée en retour serait un mensonge.

S'abonner

Depuis le tableau de bord Fleeex, ou avec la session de votre compte :

PUT /apps/{appId}/webhook
 
{ "endpointUrl": "https://api.example.com/hooks/fleeex",
  "events": ["balance.low", "topup.completed"],
  "lowBalanceThresholdMicros": 2000000 }

lowBalanceThresholdMicros est obligatoire quand balance.low est souscrit, et refusé sinon. L'endpoint doit être en https et routable publiquement, sans identifiants dans l'URL, sans localhost, et sans adresse privée ou de lien local. Utilisez un tunnel en développement.

La réponse de l'appel qui crée l'abonnement contient signingSecret (whsec_…), affiché une seule fois. Stockez-le là où votre serveur peut le lire.

Route
GET /apps/{appId}/webhookRelire l'abonnement, dont status et secretFingerprint, une empreinte non secrète qui vous dit avec quel secret votre endpoint doit vérifier.
POST /apps/{appId}/webhook/secretFaire tourner le secret.
DELETE /apps/{appId}/webhookArrêter immédiatement les livraisons et détruire le secret.

La rotation n'a pas de fenêtre de recouvrement. Il existe exactement un secret actif, et le précédent cesse de vérifier à l'instant où le nouveau est rendu. Déployez donc le nouveau secret d'abord, puis faites la rotation.

La livraison

POST /hooks/fleeex
content-type: application/json
user-agent: fleeex-webhooks/1
x-fleeex-event-id: evt_9f2c1ab04d7e5688c3a1bd40f7e21c93
x-fleeex-event-type: balance.low
x-fleeex-delivery-attempt: 1
x-fleeex-signature: t=1785240000,v1=6f1c…64 caractères hex minuscules…
 
{"id":"evt_9f2c…","type":"balance.low","createdAt":"2026-07-28T11:30:00.000Z",
 "appId":"…","data":{"userId":"votre-propre-id-utilisateur","availableMicros":1500000,
 "thresholdMicros":2000000,"currency":"EUR"}}

userId est l'identifiant dont vous vous êtes porté garant dans x-fleeex-user. Une charge utile ne contient jamais rien que vous ne puissiez déjà lire pour cet utilisateur avec votre propre clé : ni prompt, ni complétion, ni identifiant de modèle, ni clé d'API, ni e-mail, ni montant payé, ni identifiant d'identité interne à Fleeex. Ce dernier est partagé entre toutes les applications que la personne a connectées : le divulguer permettrait à deux applications de découvrir qu'elles servent le même humain.

Vérifier la signature

Implémentable dans n'importe quel langage. Le schéma :

v1 = HMAC_SHA256( clé = utf8(votre secret de signature, "whsec_" compris),
                  msg = utf8( t + "." + <corps brut de la requête> ) )

Dans cet ordre :

  1. Extrayez t et v1 de x-fleeex-signature. Tolérez les éléments inconnus et n'importe quel ordre.
  2. Recalculez le HMAC sur `${t}.${rawBody}` à partir des octets bruts de la requête. Si votre framework parse le JSON avant que vous ne le voyiez, conservez le corps brut : resérialiser l'objet parsé donne une empreinte différente.
  3. Comparez en temps constant.
  4. Seulement ensuite, vérifiez la fraîcheur : rejetez si t s'écarte de plus de 300 secondes (5 minutes) de l'instant présent.
  5. Dédupliquez sur l'identifiant d'événement.

L'ordre des points 3 et 4 est délibéré : rien de la matière signée n'est cru avant d'avoir été authentifié, si bien qu'un en-tête forgé avec un horodatage plausible est signalé comme une contrefaçon. Et l'horodatage est à l'intérieur de la matière signée : il ne peut donc pas être avancé pour contourner la vérification de fraîcheur sans casser la signature, et c'est ce qui en fait une borne anti-rejeu plutôt qu'une décoration.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
export function verifyFleeexWebhook(
  rawBody: string,
  header: string,
  secret: string,
): boolean {
  const parts = new Map(
    header.split(",").map((el) => {
      const i = el.indexOf("=");
      return [el.slice(0, i).trim(), el.slice(i + 1).trim()] as const;
    }),
  );
  const t = parts.get("t");
  const v1 = parts.get("v1");
  if (!t || !v1) return false;
 
  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"),
  );
  const actual = Buffer.from(v1);
  if (expected.length !== actual.length) return false;
  if (!timingSafeEqual(expected, actual)) return false;
 
  // La signature d'abord, la fraîcheur ensuite.
  return Math.abs(Math.floor(Date.now() / 1000) - Number(t)) <= 300;
}

v1 est une étiquette de version : si Fleeex ajoute un jour un second schéma, il arrivera sous la forme d'un élément v2= supplémentaire, et un code qui ne vérifie que v1 continuera de fonctionner.

Dédupliquer sur l'identifiant d'événement

Vous recevrez de temps en temps deux fois le même événement, car la livraison est au-moins-une-fois.

L'id (également envoyé dans x-fleeex-event-id) est dérivé du changement d'état et non de la tentative : une relivraison porte donc le même identifiant et un corps identique à l'octet près, seul l'horodatage de signature diffère. De même, createdAt est l'instant du changement d'état, pas celui de la tentative.

Stockez les identifiants que vous avez traités, considérez une répétition comme déjà faite, et répondez 2xx.

Réessais, et à quoi ressemble un abandon

  • Répondez 2xx dès que vous avez accepté l'événement de façon durable : mettez-le dans votre propre file et faites le travail ensuite. Fleeex attend au plus 5 secondes par tentative.
  • Tout code non 2xx, tout dépassement de délai et toute erreur de connexion sont réessayés : six tentatives en tout, avec un délai fixe de 10 s, 20 s, 40 s, 80 s et 160 s entre elles, si bien que la dernière tentative d'un événement tombe environ 5 minutes après la première. x-fleeex-delivery-attempt vous dit laquelle vous avez sous les yeux. Il n'y a pas de gigue, car les réessais sont propres à chaque message : le calendrier est donc prévisible, et c'est voulu.
  • Les redirections ne sont pas suivies. Une redirection est une seconde URL que personne n'a validée.
  • Après la sixième tentative, l'événement est abandonné et ne sera jamais livré. Rien ne le relivrera plus tard : un endpoint indisponible pendant une heure perd donc ces événements.
  • Si des événements épuisent leurs réessais de façon répétée, Fleeex désactive votre abonnement et cesse d'émettre. GET /apps/{appId}/webhook affiche alors status: "disabled" avec disabledReason: "delivery_failures". Réparez l'endpoint puis refaites un PUT de l'abonnement pour le réactiver : c'est le même geste que « c'est réparé », et il remet le compteur à zéro. Une livraison réussie le remet à zéro aussi.

Les webhooks sont donc un signal opportun, pas un journal durable. Si un chiffre doit être juste, relisez-le avec getBalance(). L'événement, lui, vous dit quand regarder.

Pas pour le trafic bac à sable

Une clé de bac à sable ne touche aucun portefeuille réel : il n'y a donc rien à notifier, et aucun webhook n'est jamais produit pour elle. L'exclusion est structurelle plutôt qu'un filtre, ce qui signifie aussi que le traitement des webhooks est le seul chemin que vous ne pouvez pas répéter avec une clé de test.