Tableau de bord ↗ العربية

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êteDétail
x-lahsab-signatureHMAC-SHA256, en hexadécimal, de {timestamp}.{corps brut}, avec le secret de l’environnement.
x-lahsab-timestampEpoch en secondes. Entre dans le calcul de la signature et borne le rejeu.
x-lahsab-event-idIdentifiant stable de l’événement, partagé par toutes ses tentatives de livraison. Votre clé de déduplication.
x-lahsab-environmentsandbox 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 :

  1. 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, pas express.json.

  2. Recalculez HMAC_SHA256(secret, "{timestamp}.{corps brut}") en hexadécimal, et comparez à x-lahsab-signature en temps constant (crypto.timingSafeEqual), jamais avec ===.

  3. Refusez au-delà de 5 minutes. Si l’écart entre x-lahsab-timestamp et votre horloge dépasse 300 secondes, rejetez : une signature valide qu’on vous rejoue plus tard ne doit pas passer.

  4. 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.

Vérifier et traiter
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": { }
}
Familledata 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_createdUn intent est créé.
payment.proof_submittedL’acheteur a déposé sa preuve.
payment.proof_analyzedL’analyse de cette preuve a rendu son verdict.
payment.proof_validatedLa preuve est jugée cohérente. Le paiement n’est pas confirmé pour autant.
payment.confirmedL’argent est confirmé reçu.
payment.rejectedLe paiement est rejeté.
payment.expiredLe lien de paiement a expiré sans confirmation.
Événement de facturationÉmis quand
customer.created · customer.updatedUn client est créé ou modifié.
customer.subscription.createdUn abonnement est souscrit.
customer.subscription.updatedStatut, période ou annulation programmée changent.
customer.subscription.deletedL’abonnement est terminé (canceled).
invoice.createdUne facture est créée (draft).
invoice.finalizedElle 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.overdueL’échéance est passée, la facture est toujours open.
invoice.marked_uncollectibleLa grâce est épuisée : irrécouvrable.
invoice.voidedLe 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.

proofValidationSourceCe qui s’est passé
analysisL’analyse a rendu un verdict match avec un montant positivement lu.
manualLe 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 :

  1. le payload porte déjà invoice.periodEnd et invoice.subscriptionId. Accordez un accès provisoire jusqu’à cette date, sans enregistrer que c’est payé ;
  2. côté lahsab rien n’a bougé : la facture reste open, l’abonnement ne passe pas active, currentPeriodEnd n’avance pas. invoice.paid viendra plus tard consolider ;
  3. écoutez payment.rejected, invoice.marked_uncollectible et customer.subscription.updated pour révoquer. Sans ça, une fausse preuve devient un accès permanent ;
  4. 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 un payment.rejected, et en exigeant invoice.paid pour 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

  1. Répondez 2xx rapidement. Tout code hors 2xx, toute redirection (3xx), et tout timeout au-delà de 10 secondes comptent comme un échec. Accusez réception, puis traitez en asynchrone.

  2. Les réessais sont automatiques, jusqu’à 8 tentatives, avec un backoff croissant : 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, puis 24 h. Au-delà, la livraison passe en failed.

  3. 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_analyzed porte le verdict d’une preuve sans avoir touché à l’intent : il arrive donc à la même version que le payment.proof_submitted qui 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 ?