CodeGym /Cours /ChatGPT Apps /Webhooks et intégrations externes : signature, timeouts, ...

Webhooks et intégrations externes : signature, timeouts, idempotence

ChatGPT Apps
Niveau 15 , Leçon 3
Disponible

1. Webhooks dans ChatGPT App: qui appelle qui, au juste

Dans le monde HTTP classique, tout est simple : vous êtes le client, vous faites un POST /api/..., le serveur répond, tout le monde est content. Avec les webhooks, c’est l’inverse : un service externe initie lui‑même une requête HTTP vers votre backend lorsque quelque chose s’est produit à l’extérieur.

Dans l’écosystème des ChatGPT Apps, cela apparaît dans plusieurs scénarios typiques. Par exemple, GiftGenius, après la création d’un checkout via ACP/Instant Checkout, reçoit du prestataire de paiement une notification payment_succeeded par webhook. Ou bien un service d’arrière‑plan de génération d’aperçus d’images de cadeaux vous envoie image_ready lorsque le rendu est terminé. Dans ces cas, ChatGPT et votre serveur MCP ont déjà tout fait, la balle est dans le camp du service tiers, et il vous communique le résultat via un webhook.

Particularité clé : l’initiative vient de l’extérieur de votre système. La requête peut arriver à tout moment et autant de fois que nécessaire. C’est pourquoi il faut considérer votre handler de webhook comme un point potentiellement très vulnérable — c’est littéralement la porte sur laquelle tout l’internet peut frapper.

Petit tableau pour contraste :

Type d’appel Qui initie Exemple dans GiftGenius
Requête API classique Vous Le serveur MCP appelle l’API Stripe
Webhook Le monde extérieur Stripe envoie payment_succeeded chez vous

2. Schéma simple: où sont ChatGPT, MCP et le webhook

Schématiquement, le flux ressemble à ceci :

sequenceDiagram
    participant User as Utilisateur dans ChatGPT
    participant GPT as ChatGPT + modèle
    participant App as GiftGenius (MCP/App)
    participant PSP as Passerelle de paiement (Stripe/ACP)

    User->>GPT: "Je veux acheter un cadeau"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (checkout info)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (événement reçu)
    App->>DB: marquer la commande comme payée

La première partie — des requêtes sortantes classiques que vous savez déjà faire. Le webhook — c’est la partie inférieure du schéma, où la passerelle de paiement vient frapper chez vous. C’est précisément ce qui nous intéresse aujourd’hui.

3. Gestionnaire de webhook de base dans Next.js (squelette)

Nous continuons à développer notre GiftGenius pédagogique sur Next.js 16. Dans le template, nous avons app/ pour l’UI et app/mcp/route.ts pour le serveur MCP.

Le gestionnaire de webhook a logiquement sa propre route HTTP, par exemple : app/api/webhooks/commerce/route.ts.

Le squelette minimal ressemble à ceci :


// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. Lire le corps en texte brut
  const headers = Object.fromEntries(req.headers); // 2. Récupérer les en-têtes

  // 3. TODO: validation de la signature (on l’ajoute juste après)
  // 4. TODO: parsing du JSON et traitement de l’événement

  return new Response("ok", { status: 200 }); // 5. Répondre vite en 2xx
}

Il y a déjà ici plusieurs idées importantes.

Premièrement, nous lisons le corps en texte et non pas directement avec await req.json(). Beaucoup de fournisseurs signent le flux binaire « brut » du corps, et si vous l’analysez (et pire, le reformatez) avant de vérifier la signature, celle‑ci ne correspondra plus.

Deuxièmement, nous visons une réponse rapide en 2xx. Il vaut mieux déporter le travail lourd dans un worker dédié, ou au moins dans une fonction asynchrone après avoir journalisé l’événement. C’est directement lié aux timeouts et aux renvois (retries), dont nous parlerons un peu plus loin.

4. Signature des webhooks: comment distinguer « Stripe » d’un « gars avec curl »

Souvenons‑nous du TODO dans le squelette du gestionnaire de webhook — « validation de la signature ». Voyons comment distinguer le vrai Stripe d’un « gars avec curl ».

La plus grande naïveté consiste à croire que si l’URL est compliquée (/api/webhooks/stripe/super-secret-abc123), personne ne la trouvera. Les secrets dans l’URL, c’est en fait de la security through obscurity : tenter de se cacher derrière une URL compliquée offre une protection très faible. La bonne ligne de défense, c’est la signature cryptographique.

La plupart des fournisseurs sérieux (Stripe, ACP, de nombreux CRM) calculent une signature HMAC sur le corps de la requête et l’horodatage, puis placent le résultat dans un en‑tête. De votre côté, vous faites la même chose et comparez. Au moindre écart — vous rejetez la requête comme falsifiée.

Recette générale :

  1. Vous avez un secret de webhook, obtenu dans le tableau de bord du fournisseur et stocké dans les secrets d’environnement (par exemple STRIPE_WEBHOOK_SECRET dans les variables Vercel).
  2. Le fournisseur, lors de l’envoi, calcule un HMAC sur timestamp + '.' + rawBody.
  3. Il écrit dans un en‑tête, par exemple Stripe-Signature, le timestamp et une ou plusieurs signatures.
  4. Dans votre handler, vous récupérez le timestamp, calculez votre HMAC selon la même règle et comparez.

Mini‑exemple en TypeScript avec crypto :

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // choisir l’algorithme
    .update(payload, "utf8")       // texte brut du corps
    .digest("hex");                // chaîne hex
}

Exemple de vérification de signature et de fraîcheur de l’événement :

const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });

const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
  return new Response("timestamp too old", { status: 400 });
}

const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
  process.env.STRIPE_WEBHOOK_SECRET!,
  payload
);

if (!crypto.timingSafeEqual(
  Buffer.from(expectedSig, "hex"),
  Buffer.from(theirSig, "hex")
)) {
  return new Response("invalid signature", { status: 400 });
}

Notez timingSafeEqual — c’est une protection contre les attaques temporelles, où un attaquant essaie de deviner la signature à partir de la durée de comparaison.

Après une vérification réussie de la signature, vous pouvez sans risque faire JSON.parse(rawBody) ou await req.json(), en sachant que cela provient d’un vrai fournisseur.

Des niveaux de défense supplémentaires comme une allowlist d’IP (autoriser uniquement les adresses du fournisseur) et un domaine dédié pour les webhooks ne font pas de mal, mais c’est bien la signature crypto qui vous assure l’authenticité.

5. Timeouts, réponse rapide et traitement asynchrone

Les webhooks aiment ceux qui répondent vite. La plupart des plateformes de paiement et de commerce s’attendent à ce que votre endpoint réponde en 2xx en quelques secondes (souvent jusqu’à 10 secondes, parfois moins). Si vous « réfléchissez » trop longtemps, elles considèrent l’appel comme un échec et commencent à renvoyer la requête.

De manière naïve, cela ressemble à : vous vérifiez la signature, vous allez en base, vous appelez encore trois API externes, vous générez un rapport, vous produisez un PDF, et seulement ensuite vous renvoyez un 200 OK. Si l’une de ces étapes prend un peu trop de temps, la passerelle de paiement estimera que le webhook a échoué et le renverra. Résultat : vous créerez la commande deux fois, enverrez deux emails, appellerez deux fois un GPT‑tool — et vous devrez rattraper le chaos.

Le bon pattern s’énonce « reçu, enregistré, différé » :

  1. Vérifier la signature et les invariants de base (type d’événement, champs obligatoires).
  2. Écrire rapidement l’événement dans une table/file (minimum d’opérations en base).
  3. Renvoyer un 2xx.
  4. Traiter l’événement en arrière‑plan, via un worker séparé.

Exemple simplifié de gestionnaire « semi‑correct » sans file dédiée mais avec une fixation rapide :

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifySignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await saveWebhookEvent(event); // écriture rapide en base

  // Ici, on peut envoyer la tâche en arrière-plan via setImmediate/une file,
  // mais dans cet exemple pédagogique nous nous limitons à l’enregistrement : on l’appelle sans await,
  // pour que la réponse 200 parte immédiatement.
  processWebhookEventLater(event).catch(console.error);

  return new Response("ok", { status: 200 });
}

Notez que nous ne faisons pas await processWebhookEventLater(...). Le gestionnaire place la tâche en arrière‑plan et renvoie immédiatement 200, afin de ne pas buter sur les timeouts du webhook.

En production, on introduit souvent une file (par exemple, une table dédiée webhook_jobs ou un service externe), et des workers consomment proprement les événements sans bloquer la réception des nouveaux.

6. Idempotence et déduplication: comment éviter de débiter deux fois

Dans les exemples pédagogiques, on aime dessiner des flèches parfaites : un événement → un traitement → une commande heureuse. Dans la vraie vie, les webhooks arrivent par lots et plusieurs fois d’affilée.

Les raisons sont simples : le réseau est peu fiable, les timeouts arrivent, et beaucoup de fournisseurs renvoient volontairement les événements jusqu’à recevoir un 2xx assuré. C’est particulièrement important pour les paiements : mieux vaut renvoyer payment_succeeded que le perdre définitivement.

Votre logique métier doit donc être idempotente : retraiter le même événement ne doit pas modifier le résultat (ou au moins ne doit pas casser le système).

Patron typique :

  1. L’événement possède un identifiant stable, par exemple event.id ou checkout_session_id.
  2. Vous le stockez dans une table d’événements traités et appliquez un index unique sur ce champ.
  3. À chaque webhook, vous vérifiez d’abord : s’il existe déjà un enregistrement avec cet id et le statut « traité », vous répondez simplement 200 sans rien faire.

Mini‑exemple avec un pseudo‑ORM :

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // déjà traité
  }

  await db.$transaction(async (tx) => {
    await tx.webhookEvents.upsert({
      where: { providerId: event.id },
      update: { processedAt: new Date() },
      create: {
        provider: "stripe",
        providerId: event.id,
        type: event.type,
        payload: event,
        processedAt: new Date(),
      },
    });

    await tx.orders.update({
      where: { checkoutSessionId: event.data.object.id },
      data: { status: "PAID" },
    });
  });
}

Le point important ici, c’est la transaction : vous marquez en même temps l’événement comme traité et vous modifiez la commande. Si tout plante au milieu, la transaction est annulée, et lors de la prochaine retransmission du webhook, vous réessayerez sans double écriture.

Bonne pratique : rendre idempotente l’opération elle‑même, par exemple :

  • « mettre le statut de la commande à PAID » plutôt que « augmenter le solde de +100 » ;
  • « créer l’enregistrement s’il n’existe pas » plutôt que « ajouter une ligne supplémentaire ».

7. Validation des données de webhook et PII: la signature n’est pas le seul filtre

Même signé et provenant d’un service réel, un webhook doit être traité avec la même méfiance que des entrées utilisateur ou des arguments d’outils. Dans le cours précédent, nous avons déjà évoqué que les schémas et la normalisation — c’est votre pare‑feu.

Un schéma d’événement peut, par exemple, ressembler à ceci (au niveau TypeScript/Zod) :

import { z } from "zod";

const paymentSucceededSchema = z.object({
  id: z.string(),
  type: z.literal("payment_succeeded"),
  data: z.object({
    object: z.object({
      id: z.string(),            // checkout_session_id
      amount_total: z.number(),
      currency: z.string(),
      metadata: z.record(z.string(), z.string()).optional(),
    }),
  }),
});

Dans le gestionnaire, vous validez :

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// on ne travaille ensuite qu’avec parsed

Vous vous protégez ainsi des surprises du genre « le fournisseur a changé le format », « en environnement de test, le champ est devenu nullable », etc. Si quelque chose cloche, journalisez l’erreur et renvoyez 400 ; le fournisseur renverra plus tard ou vous alertera.

N’oubliez pas non plus les PII : les corps de webhooks contiennent souvent des emails, des adresses de livraison, parfois même des bribes de données de paiement (tokenisées). Les masquer dans les logs et ne pas les envoyer en clair vers des services APM/logs tiers est une pratique obligatoire, dont nous avons parlé à propos des secrets et des données confidentielles.

Et surtout, n’envoyez pas sans filtre le JSON complet du webhook à ChatGPT comme ToolOutput — le modèle n’a pas à voir tout ce que le prestataire de paiement a transmis, surtout si ce n’est pas nécessaire pour l’UX.

8. GiftGenius en pratique: webhook de paiement pour ACP/Instant Checkout

Revenons à notre GiftGenius. Dans le module consacré au commerce et à ACP, nous avons déjà vu comment l’agent crée une session de checkout et comment, via Instant Checkout, le débit a lieu. Du point de vue de notre backend, il suffit ensuite d’attendre le webhook order.paid (ou checkout.session.completed en termes Stripe), afin de :

  • fixer le statut de la commande ;
  • déclencher la chaîne « envoyer un email » / « préparer l’expédition » ;
  • donner à l’agent une réponse fiable « le paiement a été effectué ».

Exemple de gestionnaire simple dans Next.js :

// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifyCommerceSignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === "payment_succeeded") {
    // Gestionnaire idempotent de la section précédente
    await handlePaymentSucceeded(event);
  }

  return new Response("ok", { status: 200 });
}

La fonction verifyCommerceSignature implémente une logique de signature HMAC analogue à celle que nous avons étudiée plus haut. Dans un vrai projet, il est pertinent d’avoir un module par fournisseur (verifyStripeSignature, verifyACPCheckoutSignature) pour ne pas mélanger les schémas.

À l’intérieur de handlePaymentSucceeded, vous :

  • validez l’objet via un schéma (Zod) ;
  • dans une transaction, marquez l’événement comme traité et mettez à jour la commande ;
  • optionnellement, placez une tâche en file pour les actions « lentes » : emails, analytics, appels d’API supplémentaires.

Cette approche rend la chaîne « ACP → webhook → GiftGenius » robuste face aux événements répétés, aux pannes temporaires et aux données étranges.

9. Où les webhooks s’articulent avec MCP, ChatGPT et les outils

À première vue, les webhooks semblent vivre à part de la ChatGPT App : une route HTTP dans le backend, et basta. En réalité, c’est une brique importante de l’architecture globale.

Généralement, l’enchaînement ressemble à ceci :

  1. L’outil MCP create_checkout est appelé par le modèle dans ChatGPT.
  2. Le serveur MCP contacte le prestataire de paiement, crée une session de checkout et renvoie dans le ToolOutput les informations de commande et le statut « en attente de paiement ».
  3. L’utilisateur finalise le paiement dans l’UI (Instant Checkout le fait directement dans ChatGPT).
  4. La passerelle de paiement envoie un webhook à votre backend.
  5. Le backend met à jour le statut de la commande via la base de données ; lors du prochain appel d’outils ou d’un follow‑up par le modèle, on peut dire honnêtement : « La commande est payée, voici les détails ».

Parfois, le backend peut déclencher un follow‑up de manière indirecte — par exemple, via un widget ou une intégration Realtime, qui appelle sendFollowUpMessage sur signal du serveur. Mais même si ce n’est pas le cas, le fait que le paiement ait réussi est stocké chez vous, et lors d’un prochain appel d’outil, le backend lira le nouveau statut en base et renverra au modèle des données à jour pour la réponse.

Important : le webhook est un point d’entrée qui vit au même niveau que le serveur MCP et utilise les mêmes services (base de données, files, secrets). La logique de sécurité est la même : droits minimaux, entrées validées, journalisation maîtrisée.

10. Erreurs courantes lors du travail avec des webhooks et des intégrations externes

Erreur n° 1: absence de vérification de la signature du webhook.
Parfois, les développeurs se contentent d’une URL « secrète » ou d’un simple Bearer my-secret dans l’en‑tête. Si ce secret fuit, n’importe qui peut vous envoyer des webhooks, créer des commandes, changer des statuts de paiement et faire à peu près n’importe quoi. La bonne approche — signature cryptographique du corps (HMAC) et vérification du timestamp. Cela rend la falsification nettement plus difficile que « deviner l’URL ».

Erreur n° 2: traitement lourd à l’intérieur de la requête de webhook.
Coder dans le handler du webhook « créer une commande, appeler deux API externes, générer un PDF, appeler un modèle GPT, envoyer 5 emails » — c’est le meilleur moyen d’attraper des timeouts et des tentatives répétées. Au final, vous créerez vous‑même des doublons qu’il faudra démêler. Il est bien plus fiable de confirmer rapidement la réception de l’événement (2xx), de l’enregistrer en base ou en file et de le traiter en arrière‑plan.

Erreur n° 3: logique métier non idempotente.
On voit souvent du code du type « à chaque payment_succeeded, augmenter le solde du montant ». Si le webhook arrive deux fois, le solde double. Autre variante — créer deux fois la même commande ou envoyer deux fois un email. On obtient l’idempotence via un identifiant d’événement stable, une table d’événements traités, des transactions et des opérations du type « fixer un statut » plutôt que « ajouter encore ».

Erreur n° 4: absence de schémas et de validation des données du webhook.
Même un webhook signé peut ne pas être ce que vous attendiez : le fournisseur a changé le format, vous copiez un JSON depuis la documentation, mais en environnement de test le champ s’appelle autrement, ou vous vous êtes simplement trompé de types. Si vous traitez ce JSON sans schémas ni contrôles, les erreurs casseront silencieusement les commandes ou provoqueront des exceptions au milieu de la chaîne. Utiliser Zod/JSON Schema à l’entrée simplifie le diagnostic et permet d’écarter clairement les événements invalides.

Erreur n° 5: journaliser les corps bruts des webhooks avec des PII.
Dans le feu du débogage, on met facilement un console.log(rawBody) et on l’oublie. En production, cela devient des logs remplis d’emails, d’adresses et d’autres PII, qui partent vers des services de logs tiers. Du point de vue de la confidentialité et des réglementations (type RGPD), c’est un tir dans le pied. Mieux vaut mettre en place un « PII‑scrub » — masquer les champs sensibles et ne journaliser que ce qui est vraiment utile au diagnostic.

Erreur n° 6: mélange des webhooks de test et de production.
Cas typique — le même endpoint reçoit des événements à la fois de l’environnement de test et de production du fournisseur. Résultat : un paiement de test modifie soudain le statut d’une commande réelle, ou l’inverse. Il est plus sûr de séparer les URL (par exemple, /webhooks/commerce/test et /webhooks/commerce/live) ou au moins de conserver un « mode » dans la configuration et de le vérifier à l’entrée.

Erreur n° 7: dépendance totale du scénario ChatGPT à un webhook synchrone.
On peut être tenté de vouloir que, juste après l’appel de l’outil et la création de la session de checkout, le modèle connaisse immédiatement le résultat du paiement. Mais par définition, les webhooks sont asynchrones, et le paiement peut prendre du temps. Concevoir le scénario comme si tout se produisait instantanément est une mauvaise idée. Il vaut mieux concevoir les dialogues et les outils pour qu’ils gèrent correctement les événements différés : sauvegarder l’état de la commande, permettre à l’utilisateur de revenir dans le chat et d’obtenir l’information à jour plus tard.

Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION