Des handlers de webhooks qui tiennent en production
Guides

Des handlers de webhooks qui tiennent en production

Des signatures à travers une rotation de secret, une échelle de huit tentatives et un handler qui peut s'exécuter deux fois - la checklist des webhooks cmssy.

É
Équipe Cmssy
5 min read

Des handlers de webhooks qui tiennent en production

Ce qu'est une livraison de webhook cmssy, comment vérifier sa signature, ce que fait l'échelle de tentatives et comment écrire un handler qui peut s'exécuter deux fois sans risque.

Un handler de webhook ressemble à dix lignes de code - jusqu'au jour où il comptabilise un paiement deux fois, perd la moitié de ses livraisons pendant une rotation de secret ou cesse silencieusement de recevoir quoi que ce soit après un déploiement raté. Rien de tout cela n'est exotique. C'est ce qui arrive quand un handler suppose qu'une livraison arrive une seule fois, dans l'ordre, signée avec l'unique secret qu'il connaît.

Cet article est la checklist d'un handler de webhooks cmssy qui tient en production : ce qu'est une livraison, comment la vérifier, ce que fait l'échelle de tentatives et comment écrire un handler qui peut s'exécuter deux fois sans risque.

Ce qu'est une livraison

Chaque livraison est un POST avec un corps JSON :

{
  "id": "0b6f2c1e-…",
  "event": "order.paid",
  "createdAt": "2026-10-26T14:21:19.846Z",
  "data": { "workspaceId": "…", "order": { … } }
}

L'id est l'id de la livraison. Il reste identique à chaque nouvelle tentative de cette livraison et arrive aussi dans l'en-tête x-cmssy-webhook-id, à côté de x-cmssy-event et x-cmssy-signature. Relancer une livraison depuis l'admin ou avec redeliver_webhook_delivery crée une nouvelle livraison avec un nouvel id : un handler qui déduplique sur l'id traite donc une relance manuelle comme nouvelle - ce qui est généralement ce que vous voulez quand vous relancez volontairement.

Les payloads sont légers : les événements de contenu portent des références, pas le contenu, et form.submitted porte l'id de la soumission, pas les réponses. Les événements commerce portent l'instantané de commande dont votre intégration a besoin - statut, totaux, traitement fiscal, acheteur, colis et factures.

Vérifier la signature

L'en-tête x-cmssy-signature : t est un horodatage en millisecondes, v1 est un HMAC-SHA256 de l'horodatage, d'un point et du corps brut avec le secret de l'endpoint comme clé, et un second v1 apparaît pendant une rotation de secret.
Un horodatage, une signature par secret actif.

L'en-tête se lit t=<unix-ms>,v1=<hex>. La valeur hex est un HMAC-SHA256 sur <t>.<raw body>, avec le secret de l'endpoint comme clé. Trois détails piègent les vérificateurs écrits à la main :

  • Une rotation envoie deux signatures. Pendant la rotation d'un secret, l'en-tête porte un v1 par secret actif. Un vérificateur qui ne contrôle que le dernier rejette chaque livraison signée avec l'autre, tant que dure la rotation.
  • Le corps doit être brut. Parsez le JSON puis re-sérialisez-le, et les octets changent - ordre des clés, espaces, échappements unicode. Vérifiez await request.text(), puis parsez.
  • Les anciens horodatages doivent échouer. Sans fenêtre de tolérance, une livraison interceptée peut être rejouée indéfiniment.

Le helper du SDK fait les trois : il lit chaque v1, compare en temps constant, rejette un horodatage de plus de cinq minutes et renvoie l'événement typé. Il est fourni dans @cmssy/next, @cmssy/remix et @cmssy/astro, et dans @cmssy/core pour tout le reste.

import { verifyCmssyWebhook, CmssyWebhookError } from "@cmssy/next";

export async function POST(request: Request) {
  const body = await request.text();
  try {
    const event = await verifyCmssyWebhook({
      body,
      signatureHeader: request.headers.get("x-cmssy-signature"),
      secret: [process.env.CMSSY_WEBHOOK_SECRET, process.env.CMSSY_WEBHOOK_SECRET_PREVIOUS]
        .filter((s): s is string => !!s),
    });
    await handle(event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof CmssyWebhookError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

L'option secret accepte un tableau : votre handler peut donc conserver le nouveau secret et le précédent pendant votre propre fenêtre de rotation et accepter les livraisons signées avec l'un ou l'autre.

L'échelle de tentatives

Huit tentatives de livraison : la première, puis plus 1 minute, 5 minutes, 30 minutes, 2 heures, 5 heures, 10 heures et 10 heures, soit environ 27 heures au total.
Huit tentatives sur environ 27 heures.

cmssy attend une réponse pendant 15 secondes. Tout ce qui n'est pas un 2xx - et tout timeout - est retenté : huit tentatives en tout, espacées de 1 minute, 5 minutes, 30 minutes, 2 heures, 5 heures, 10 heures et 10 heures. Un handler indisponible pendant un après-midi ne perd rien.

  • Les redirections sont des échecs. Un 3xx n'est pas suivi. Enregistrez l'URL finale.
  • Seul 410 Gone arrête l'échelle. Tout autre 4xx est retenté, car « cet endpoint est terminé pour de bon » et « le déploiement propriétaire de cette route répond brièvement 404 » se ressemblent vus de l'extérieur. Renvoyez 410 quand c'est vraiment ce que vous voulez dire.
  • Retry-After est respecté lorsqu'il demande une attente plus longue que celle de l'échelle, dans la limite de 10 heures. Il ne raccourcit jamais le backoff.
  • Vingt échecs consécutifs désactivent l'endpoint. Corrigez le handler, puis réactivez-le ; cela remet le compteur à zéro.

Les livraisons sont mises en file par endpoint, une seule tentative en cours à la fois : un handler lent met ainsi ses propres tentatives en file au lieu d'être submergé par un fan-out.

Écrire un handler qui peut s'exécuter deux fois

Une livraison at-least-once signifie que votre handler verra tôt ou tard la même livraison deux fois - un timeout de votre côté que cmssy a compté comme un échec, un déploiement qui a répondu 502 après que le travail a été fait. Et comme la livraison n'est pas ordonnée, une nouvelle tentative peut arriver après un événement plus récent concernant la même commande.

  • Accusez réception vite, travaillez ensuite. Vérifiez, stockez la livraison, renvoyez un 2xx. Un handler qui appelle trois API avant de répondre finira par dépasser 15 secondes et sera relancé alors qu'il travaille encore.
  • Dédupliquez sur l'id de livraison. Un index unique sur id dans votre propre table transforme un doublon en no-op.
  • Lisez l'état actuel avant les effets de bord. Le payload est un instantané du moment de l'événement. Quand cela compte - émettre une facture, envoyer un e-mail, déplacer du stock dans un autre système - relisez la commande et agissez sur ce qui est vrai maintenant.
  • Ne vous fiez jamais à l'ordre d'arrivée. Comparez l'état du payload, ou de la relecture, à ce que vous avez déjà appliqué.

Quand quelque chose tourne mal

Chaque tentative est journalisée : list_webhook_deliveries (ou Settings → Webhooks dans l'admin) affiche le statut, le code de réponse et, pour les membres de l'équipe disposant de webhooks:manage, les 8 premiers KiB du corps de réponse de votre handler - généralement la stack trace que vous cherchiez. Une fois le handler corrigé, relancez les livraisons échouées. Une relance est une nouvelle livraison avec son propre id ; elle ne compte ni dans l'échelle de l'originale ni dans la série d'échecs de l'endpoint.

Un abonnement ou plusieurs ?

Pour l'invalidation de cache, abonnez-vous une fois à content.changed et invalidez à chaque changement ; il couvre pages, enregistrements, modèles, formulaires, médias et paramètres. Pour l'automatisation, abonnez-vous aux événements granulaires dont vous avez besoin - order.*, shipment.*, page.published, form.submitted et les autres. list_webhook_event_types renvoie la liste complète des événements autorisés, filtrée selon ce à quoi votre rôle peut s'abonner.

La référence complète se trouve dans la documentation des webhooks. Pour un exemple concret de handler idempotent, consultez facturer une boutique headless avec des webhooks.