Le webhook est l’élément central de lahsab. C’est un POST signé vers l’URL que vous déclarez dans la console Développeurs, et c’est ainsi que votre serveur apprend qu’un paiement est confirmé ou qu’une facture est payée. Aucune boucle de polling.
Chaque environnement a son endpoint et son secret. Une clé lsk_sandbox_… ne déclenche jamais une livraison vers votre URL de production, et chaque événement porte son environment.
En-têtes
| En-tête | Détail |
|---|---|
x-lahsab-signature | HMAC-SHA256, en hexadécimal, de {timestamp}.{corps brut}, avec le secret de l’environnement. |
x-lahsab-timestamp | Epoch en secondes. Entre dans le calcul de la signature et borne le rejeu. |
x-lahsab-event-id | Identifiant stable de l’événement, partagé par toutes ses tentatives de livraison. Votre clé de déduplication. |
x-lahsab-environment | sandbox ou live. |
Le secret (whsec_…) est propre à chaque environnement, généré à la première URL déclarée. Récupérez-le dans la console Développeurs.
Vérifier une livraison
Un endpoint qui ne vérifie pas la signature accepte n’importe quel POST. C’est une porte ouverte pour s’accorder un accès sans jamais payer. Quatre gestes, dans l’ordre :
-
Lisez le corps brut. La signature porte sur les octets reçus. lahsab sérialise le corps avec les clés JSON triées ; si votre framework parse le JSON puis le re-sérialise, l’ordre des clés bouge et la signature ne correspond plus. Sur Express :
express.raw, pasexpress.json. -
Recalculez
HMAC_SHA256(secret, "{timestamp}.{corps brut}")en hexadécimal, et comparez àx-lahsab-signatureen temps constant (crypto.timingSafeEqual), jamais avec===. -
Refusez au-delà de 5 minutes. Si l’écart entre
x-lahsab-timestampet votre horloge dépasse 300 secondes, rejetez : une signature valide qu’on vous rejoue plus tard ne doit pas passer. -
Dédupliquez sur
x-lahsab-event-id. Un réessai après un timeout de votre côté peut livrer deux fois le même événement. Traitez-le une seule fois.
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
const secret = process.env.LAHSAB_WEBHOOK_SECRET;
// Corps BRUT obligatoire : express.raw, pas express.json.
app.post(
"/webhooks/lahsab",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("x-lahsab-signature");
const timestamp = req.header("x-lahsab-timestamp");
const eventId = req.header("x-lahsab-event-id");
if (!signature || !timestamp || !eventId) return res.sendStatus(400);
// 1. Anti-rejeu : refuser un horodatage trop vieux (ou trop en avance).
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
// 2. Signature sur `timestamp.corpsBrut` (req.body est un Buffer).
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const valid =
expected.length === signature.length &&
crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex"),
);
if (!valid) return res.sendStatus(401);
// 3. Déduplication : le même événement peut arriver deux fois.
if (await alreadyHandled(eventId)) return res.sendStatus(200);
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "invoice.paid") {
await grantAccess(
event.data.customer.externalId,
event.data.subscription?.currentPeriodEnd,
);
}
await markHandled(eventId);
res.sendStatus(200); // 4. Répondre 2xx vite, traiter en asynchrone.
},
); Enveloppe
Tous les événements partagent la même forme. Le contenu utile est dans data, et sa forme dépend de la famille d’événements. L’identifiant de l’événement voyage dans l’en-tête x-lahsab-event-id, pas dans le corps.
{
"type": "invoice.paid",
"environment": "live",
"createdAt": "2026-07-13T10:04:00.000Z",
"data": { }
}
| Famille | data contient |
|---|---|
payment.* | L’intent lui-même, à plat (id, amount, status, checkoutUrl, customerRef, confirmationSource…). |
customer.* | customer |
customer.subscription.* | subscription (avec customer et price inclus) |
invoice.* | invoice, plus customer et subscription si la facture vient d’un abonnement. |
Catalogue
Dix-huit événements, en deux familles. Les événements de paiement existent indépendamment de la facturation : l’intent reste une primitive de premier ordre pour qui veut simplement encaisser un paiement ponctuel.
| Événement de paiement | Émis quand |
|---|---|
payment.intent_created | Un intent est créé. |
payment.proof_submitted | L’acheteur a déposé sa preuve. |
payment.proof_analyzed | L’analyse de cette preuve a rendu son verdict. |
payment.proof_validated | La preuve est jugée cohérente. Le paiement n’est pas confirmé pour autant. |
payment.confirmed | L’argent est confirmé reçu. |
payment.rejected | Le paiement est rejeté. |
payment.expired | Le lien de paiement a expiré sans confirmation. |
| Événement de facturation | Émis quand |
|---|---|
customer.created · customer.updated | Un client est créé ou modifié. |
customer.subscription.created | Un abonnement est souscrit. |
customer.subscription.updated | Statut, période ou annulation programmée changent. |
customer.subscription.deleted | L’abonnement est terminé (canceled). |
invoice.created | Une facture est créée (draft). |
invoice.finalized | Elle est finalisée : numérotée, open, payable. |
invoice.paid l'accès | Elle est payée. C’est l’événement qui accorde l’accès. |
invoice.overdue | L’échéance est passée, la facture est toujours open. |
invoice.marked_uncollectible | La grâce est épuisée : irrécouvrable. |
invoice.voided | Le marchand a annulé la facture. |
invoice.paid
C’est celui que vous branchez. Il porte tout ce qu’il faut pour accorder l’accès, sans un appel de plus : votre identifiant client (customer.externalId), et la date jusqu’à laquelle c’est payé (subscription.currentPeriodEnd).
{
"type": "invoice.paid",
"environment": "live",
"createdAt": "2026-08-13T09:12:00.000Z",
"data": {
"invoice": {
"id": "f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"number": "LSB-2026-0042",
"status": "paid",
"amountDue": 2500,
"amountPaid": 2500,
"periodStart": "2026-08-13T00:00:00.000Z",
"periodEnd": "2026-09-13T00:00:00.000Z",
"dueDate": "2026-08-20T00:00:00.000Z",
"paidAt": "2026-08-13T09:12:00.000Z",
"hostedInvoiceUrl": "https://dashboard.lahsab.com/invoice/f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"version": 3,
"updatedAt": "2026-08-13T09:12:00.000Z"
},
"customer": {
"id": "c0ffee00-1111-4222-8333-444455556666",
"externalId": "user_abc123",
"name": "Yacine B.",
"phone": "0770000000",
"version": 1,
"updatedAt": "2026-08-06T14:20:00.000Z"
},
"subscription": {
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"status": "active",
"currentPeriodStart": "2026-08-13T00:00:00.000Z",
"currentPeriodEnd": "2026-09-13T00:00:00.000Z",
"version": 5,
"updatedAt": "2026-08-13T09:12:00.000Z"
}
}
}
payment.proof_analyzed
Quand l’acheteur dépose une preuve, lahsab émet deux événements dans cet ordre, puis un troisième si la preuve tient.
payment.proof_submitted dit « quelque chose vient d’arriver ». Il part immédiatement, sans attendre personne.
payment.proof_analyzed dit ce que vaut ce fichier. Il part quelques secondes plus tard, quand l’analyse a rendu son verdict. Le payload est le même intent, mais son tableau proofs[] est cette fois rempli : analysisStatus (completed, failed, skipped), analysisVerdict (match, mismatch, suspect, unreadable) et le rapport analysis complet, avec le détail des cinq contrôles.
L’événement part dans tous les cas, y compris quand l’analyse a échoué ou n’a pas tourné. Un analysisStatus à failed n’est pas un verdict négatif, c’est l’absence de verdict.
payment.proof_validated
Une preuve jugée cohérente fait passer l’intent à l’état proof_validated. Cet état dit une seule chose : le document tient debout, l’argent n’est pas constaté. Le paiement reste dû.
Deux voies mènent à cet état, distinguées par proofValidationSource sur l’intent.
proofValidationSource | Ce qui s’est passé |
|---|---|
analysis | L’analyse a rendu un verdict match avec un montant positivement lu. |
manual | Le marchand a accepté la preuve à la main, depuis son tableau de bord. |
Aucun automate ne confirme jamais un paiement. payment.confirmed reste réservé à un constat d’argent, et confirmationSource vaut manual tant qu’aucune source automatique (SMS, relevé) n’est branchée.
Quel événement accorde l’accès
C’est votre décision, pas la nôtre. Deux stratégies, toutes les deux légitimes.
Attendre invoice.paid. L’argent est constaté, aucun risque de fraude. L’acheteur attend que le marchand ouvre son compte, ce qui peut prendre quelques heures.
Accorder sur payment.proof_validated. L’acheteur a accès quelques secondes après son dépôt. Vous prenez le risque d’une fausse preuve, et vous devez pouvoir revenir en arrière.
Si vous choisissez la seconde, trois obligations :
- le payload porte déjà
invoice.periodEndetinvoice.subscriptionId. Accordez un accès provisoire jusqu’à cette date, sans enregistrer que c’est payé ; - côté lahsab rien n’a bougé : la facture reste
open, l’abonnement ne passe pasactive,currentPeriodEndn’avance pas.invoice.paidviendra plus tard consolider ; - écoutez
payment.rejected,invoice.marked_uncollectibleetcustomer.subscription.updatedpour révoquer. Sans ça, une fausse preuve devient un accès permanent ; - sachez qu’un accès accordé tôt renseigne l’acheteur sur son propre dépôt. lahsab ne lui dit jamais qu’un fichier est passé, la page de paiement affiche rigoureusement le même état avant et après. Votre produit, lui, le lui dit en se déverrouillant, et quelqu’un de mal intentionné peut recommencer jusqu’à ce que ça marche. Le budget d’essais n’est pas mince : 10 preuves par paiement, et la facture hébergée émet un lien neuf après chaque rejet. Bornez les tentatives, par exemple en n’accordant plus rien sur
payment.proof_validatedà un client qui a déjà eu unpayment.rejected, et en exigeantinvoice.paidpour celui-là.
Impayés
lahsab n’envoie pas de relances à votre place. Pour rappeler un client, écoutez invoice.overdue : son payload porte la facture, le client (nom, téléphone) et le lien de paiement, de quoi composer votre message sans un appel supplémentaire. Le délai de grâce (gracePeriodDays) et le comportement en fin de grâce (dunningExhaustedBehavior) se règlent par marchand, dans les réglages du tableau de bord (voir Impayés).
Livraison et réessais
-
Répondez
2xxrapidement. Tout code hors2xx, toute redirection (3xx), et tout timeout au-delà de 10 secondes comptent comme un échec. Accusez réception, puis traitez en asynchrone. -
Les réessais sont automatiques, jusqu’à 8 tentatives, avec un backoff croissant :
1 min,5 min,30 min,2 h,6 h,12 h, puis24 h. Au-delà, la livraison passe enfailed. -
Le journal garde tout. La console Développeurs liste chaque livraison de l’environnement courant (code de réponse, tentatives, erreur) avec un bouton Redélivrer pour rejouer un événement à la demande.
Ordonner votre miroir
Si vous tenez une copie locale d’un intent, d’une facture, d’un abonnement, d’un client, d’un produit, d’un prix ou d’un coupon, vous ne pouvez pas appliquer les payloads dans l’ordre où ils vous arrivent. Deux situations vous servent un état périmé, et les deux sont normales.
Une livraison réessayée arrive en retard. Un customer.subscription.created refusé au premier essai peut atterrir bien après le customer.subscription.updated qui, lui, est passé du premier coup. Le dernier reçu n’est donc pas le plus récent.
Un rejeu d’idempotence renvoie la réponse d’origine. Rejouez une Idempotency-Key déjà vue et vous récupérez le corps enregistré au premier appel, pas l’état courant de l’objet. C’est le comportement attendu de l’idempotence, et ça veut dire que l’API peut vous servir un instantané vieux de plusieurs jours.
Chaque ressource porte donc un entier version, incrémenté à chaque écriture de la ligne. Il est présent partout : sur la réponse de l’API comme dans le payload webhook, avec la même valeur pour un même état.
const known = await mirror.versionOf(payload.id);
if (known !== null && payload.version < known) return; // périmé, on jette
await mirror.apply(payload);
Trois précisions qui évitent les pièges :
- Une version égale se traite, elle ne se jette pas. Une écriture ne produit pas toujours un événement, et un événement ne suppose pas toujours une écriture.
payment.proof_analyzedporte le verdict d’une preuve sans avoir touché à l’intent : il arrive donc à la même version que lepayment.proof_submittedqui le précède. Le jeter vous ferait perdre l’analyse. - Les versions ne sont pas contiguës. Un saut de 7 à 9 est normal, seul l’ordre compte.
- Une version ne se compare qu’à elle-même. C’est un compteur par ligne, pas un numéro de séquence global. La version 4 d’une facture n’a rien à voir avec la version 4 d’une autre.
updatedAt accompagne version sur ces mêmes objets, pour l’affichage et le débogage. Ne l’utilisez pas pour ordonner : lahsab écrit parfois deux fois la même ressource dans la même milliseconde, une facture d’abonnement étant créée (draft) puis finalisée (open) dans la foulée. L’horodatage ne sépare pas ces deux états, la version si.
Et ensuite ?
- Recevoir les événements sur votre machine, signés comme en production : Webhooks en local.
- Le moteur d’abonnements et sa règle d’or : Abonnements.
- Tous les endpoints, champs et erreurs : Référence API.