CodeGym /Corsi /ChatGPT Apps /Webhook e integrazioni esterne: firma, timeout, idempoten...

Webhook e integrazioni esterne: firma, timeout, idempotenza

ChatGPT Apps
Livello 15 , Lezione 3
Disponibile

1. Webhook in ChatGPT App: chi chiama chi

Nel mondo HTTP classico è tutto semplice: voi siete il client, fate POST /api/..., il server risponde e tutti sono felici. Con i webhook è il contrario: un servizio esterno avvia lui stesso una richiesta HTTP verso il vostro backend quando all’esterno succede qualcosa.

Nell’ecosistema delle ChatGPT Apps questo emerge in diversi scenari tipici. Per esempio, GiftGenius dopo aver creato un checkout tramite ACP/Instant Checkout riceve dal provider di pagamento una notifica payment_succeeded via webhook. Oppure un servizio in background che genera le anteprime delle immagini dei regali vi invia image_ready quando il rendering è terminato. In questi casi ChatGPT e il vostro server MCP hanno già fatto il loro lavoro, la palla è nel campo del servizio di terze parti e lui vi comunica l’esito tramite webhook.

La caratteristica chiave: l’iniziativa è al di fuori del vostro sistema. La richiesta può arrivare in qualsiasi momento e tutte le volte che capita. Perciò il vostro handler del webhook va considerato come un potenziale punto di massima esposizione — lì bussa tutto Internet.

Una piccola tabella per confronto:

Tipo di chiamata Chi avvia Esempio in GiftGenius
Chiamata API normale Voi Il server MCP chiama le API di Stripe
Webhook Mondo esterno Stripe vi invia payment_succeeded

2. Schema semplice: dove c’è ChatGPT, dove MCP, dove il webhook

Il flusso, in modo schematico, è così:

sequenceDiagram
    participant User as Utente in ChatGPT
    participant GPT as ChatGPT + modello
    participant App as GiftGenius (MCP/App)
    participant PSP as PSP (Stripe/ACP)

    User->>GPT: "Voglio comprare un regalo"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (info checkout)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (evento ricevuto)
    App->>DB: contrassegnare l’ordine come pagato

La prima parte — comuni richieste in uscita, che già sapete fare. Il webhook — è la parte bassa dello schema, dove il provider di pagamento bussa a voi. È proprio questo che ci interessa oggi.

3. Handler di base del webhook in Next.js (scheletro)

Continuiamo a sviluppare il nostro GiftGenius didattico su Next.js 16. Nel template abbiamo app/ con la UI e app/mcp/route.ts con il server MCP.

Ha senso estrarre l’handler del webhook in una rotta HTTP separata, ad esempio: app/api/webhooks/commerce/route.ts.

L’ossatura minima è così:


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

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. Leggiamo il body come stringa
  const headers = Object.fromEntries(req.headers); // 2. Prendiamo gli header

  // 3. TODO: validazione della firma (lo aggiungeremo tra poco)
  // 4. TODO: parsing JSON e gestione dell’evento

  return new Response("ok", { status: 200 }); // 5. Rispondiamo velocemente 2xx
}

Qui sono già nascoste alcune idee importanti.

Per prima cosa, leggiamo il body come testo, e non subito con await req.json(). Molti provider firmano proprio il flusso di byte «grezzo» del corpo; se lo parsate (e, peggio ancora, lo riformattate) prima di verificare la firma, la firma non corrisponderà più.

In secondo luogo, pensiamo subito a rispondere rapidamente con 2xx. Il lavoro pesante è meglio spostarlo in un worker separato, o almeno in una funzione async dopo aver loggato l’evento. Questo è direttamente collegato ai timeout e ai reinvii, di cui parleremo tra poco.

4. Firma dei webhook: come distinguere «Stripe» da «uno con curl»

Ricordiamo il TODO dallo scheletro dell’handler — «validazione della firma». Vediamo come distinguere lo Stripe reale da «uno con curl».

La ingenuità più grande è pensare che, se l’URL è complicato (/api/webhooks/stripe/super-secret-abc123), nessuno lo troverà. I segreti nell’URL sono, in sostanza, security through obscurity: un tentativo di nascondersi dietro un URL complesso che offre una protezione molto debole. La linea di difesa corretta è la firma crittografica.

Quasi tutti i provider seri (Stripe, ACP, molti CRM) calcolano una firma HMAC sul corpo della richiesta e sul tempo, quindi mettono il risultato in un header. Voi, come destinatari, fate lo stesso e confrontate. Se anche solo di poco non coincide — scartate la richiesta come falsificata.

Ricetta generale:

  1. Avete un segreto del webhook, ottenuto nel pannello del provider e salvato tra i secret dell’ambiente (per esempio STRIPE_WEBHOOK_SECRET nelle variabili di Vercel).
  2. Il provider, inviando la richiesta, calcola l’HMAC su timestamp + '.' + rawBody.
  3. Nell’header, ad esempio Stripe-Signature, scrive il timestamp e una o più firme.
  4. Nel vostro handler prendete il timestamp, calcolate il vostro HMAC secondo la stessa regola e confrontate.

Mini-esempio in TypeScript usando crypto:

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // scegliamo l'algoritmo
    .update(payload, "utf8")       // testo grezzo del body
    .digest("hex");                // stringa esadecimale
}

Esempio di verifica della firma e della freschezza dell’evento:

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 });
}

Notate timingSafeEqual — è una protezione contro gli attacchi temporali, in cui un attaccante tenta di indovinare la firma in base alla durata del confronto.

Dopo la verifica della firma potete tranquillamente fare JSON.parse(rawBody) o await req.json(), sapendo che proviene da un provider reale.

Ulteriori livelli di difesa come IP allowlist (consentire richieste solo dagli indirizzi del provider) e un dominio separato per i webhook non guastano, ma è proprio la firma crittografica a darvi certezza di autenticità.

5. Timeout, risposta rapida ed elaborazione asincrona

I webhook amano chi risponde in fretta. La maggior parte delle piattaforme di pagamento e commerce si aspetta che il vostro endpoint risponda con 2xx in pochi secondi (spesso fino a 10 secondi, talvolta meno). Se «pensate» troppo a lungo, considerano la chiamata non riuscita e iniziano a ripetere le richieste.

Di base può sembrare così: verificate la firma, interrogate il DB, chiamate altre tre API esterne, calcolate un report, generate un PDF e solo dopo restituite 200 OK. Se qualcosa in questo processo si blocca un po’, il provider di pagamento deciderà che il webhook è fallito e lo invierà di nuovo. Di conseguenza creerete un ordine due volte, manderete un’email due volte, invocherete due volte un qualche GPT tool — e poi correrete a sistemare il caos.

Il pattern corretto è «ricevi, registra, rinvia»:

  1. Verificare la firma e gli invarianti di base (tipo di evento, campi obbligatori).
  2. Registrare rapidamente l’evento in una tabella/coda (minimo di operazioni sul DB).
  3. Restituire 2xx.
  4. Elaborare l’evento in background, con un worker separato.

Esempio semplificato di handler «semi‑corretto» senza coda separata, ma con fissazione rapida:

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); // scrittura veloce nel DB

  // Qui si può inviare il task in background tramite setImmediate/queue,
  // ma nell'esempio didattico ci limitiamo alla scrittura: chiamiamo senza await,
  // così la risposta 200 parte subito.
  processWebhookEventLater(event).catch(console.error);

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

Attenzione: non facciamo await processWebhookEventLater(...). L’handler mette il task in background e ritorna subito 200, così da non incorrere nei timeout del webhook.

In produzione spesso qui compare una coda (per esempio, una tabella separata webhook_jobs o un servizio esterno) e i worker smaltiscono gli eventi con ordine, senza bloccare la ricezione dei nuovi.

6. Idempotenza e deduplicazione: come non addebitare due volte

Negli esempi didattici si disegnano spesso frecce ideali: un evento → una elaborazione → ordine felice. Nella vita reale i webhook arrivano a pacchetti e più volte di seguito.

Le ragioni sono semplici: la rete è inaffidabile, i timeout capitano e molti provider, per design, reinviano gli eventi finché non ricevono un deciso 2xx. È particolarmente importante per i pagamenti: meglio inviare di nuovo payment_succeeded che perderlo per sempre.

Per questo la vostra logica di business deve essere idempotente: l’elaborazione ripetuta dello stesso evento non deve cambiare il risultato (o quantomeno non deve rompere il sistema).

Pattern tipico:

  1. L’evento ha un identificatore stabile, ad esempio event.id o checkout_session_id.
  2. Lo memorizzate in una tabella degli eventi elaborati e imponete un indice univoco su questo campo.
  3. Per ogni webhook verificate prima: se esiste già una riga con quel id e stato «elaborato», rispondete 200 e non fate nient’altro.

Mini‑esempio con pseudo‑ORM:

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // è già tutto fatto
  }

  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" },
    });
  });
}

Qui è importante la transazione: contrassegnate l’evento come elaborato e modificate l’ordine contemporaneamente. Se qualcosa fallisce a metà, la transazione viene rollbackata e, al successivo reinvio del webhook, riproverete senza creare doppioni.

Una buona pratica è anche rendere idempotente l’operazione stessa, ad esempio:

  • «impostare lo stato dell’ordine a PAID» invece di «incrementare il saldo di +100»;
  • «creare la riga se non esiste» invece di «aggiungere un’ulteriore riga».

7. Validazione dei dati del webhook e PII: la firma non è l’unico filtro

Anche se un webhook è firmato e proviene da un servizio reale, conviene trattare i suoi dati con la stessa diffidenza riservata all’input utente o agli argomenti degli strumenti. Nella lezione precedente abbiamo già discusso che schemi e normalizzazione sono il vostro firewall.

Uno schema per l’evento, ad esempio, può essere così (a livello 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(),
    }),
  }),
});

Nell’handler validate così:

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// poi lavorate solo con parsed

Così vi proteggete da sorprese come «il provider ha cambiato formato», «nell’ambiente di test il campo è diventato nullable», ecc. Se qualcosa non quadra — registrate l’errore nei log e restituite 400; il provider poi ripeterà o invierà un alert.

Anche la PII va considerata: i body dei webhook spesso contengono email, indirizzi di spedizione, talvolta persino parti di dati di pagamento (in forma tokenizzata). Mascherarli nei log e non inviare i dati grezzi a servizi APM/log esterni è una pratica obbligatoria, di cui abbiamo parlato nel tema su segreti e dati confidenziali.

E sicuramente non dovete inviare senza filtro l’intero JSON del webhook a ChatGPT come ToolOutput — il modello non deve vedere tutto ciò che ha inviato il provider di pagamento, soprattutto se non serve alla UX.

8. GiftGenius in pratica: webhook di pagamento per ACP/Instant Checkout

Torniamo al nostro GiftGenius. Nel modulo su commerce e ACP abbiamo già visto come l’agente crea una sessione di checkout e come con Instant Checkout avviene l’addebito. Dal punto di vista del nostro backend dopodiché resta da attendere il webhook order.paid (o checkout.session.completed in termini Stripe), per:

  • fissare lo stato dell’ordine;
  • avviare la catena «invia email» / «prepara la spedizione»;
  • dare all’agente una risposta certa «il pagamento è andato a buon fine».

Esempio di semplice handler in 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") {
    // Handler idempotente dalla sezione precedente
    await handlePaymentSucceeded(event);
  }

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

La funzione verifyCommerceSignature implementa la logica della firma HMAC, analoga a quanto visto sopra. In un progetto reale ha senso creare un modulo per ciascun provider (verifyStripeSignature, verifyACPCheckoutSignature), così da non mescolare gli schemi.

Dentro handlePaymentSucceeded voi:

  • validare l’oggetto con uno schema (Zod);
  • in una transazione contrassegnare l’evento come elaborato e aggiornare l’ordine;
  • opzionalmente mettere un task in coda per le azioni «lente»: email, analytics, chiamate API aggiuntive.

Questo approccio rende la catena «ACP → webhook → GiftGenius» resiliente a eventi ripetuti, guasti temporanei e dati anomali.

9. Dove i webhook si collegano a MCP, ChatGPT e agli strumenti

A prima vista può sembrare che i webhook vivano separati dalla ChatGPT App: una rotta HTTP nel backend e basta. In realtà sono una parte importante dell’architettura complessiva.

Di solito l’integrazione appare così:

  1. Lo strumento MCP create_checkout è invocato dal modello in ChatGPT.
  2. Il server MCP si rivolge al provider di pagamento, crea la sessione di checkout e restituisce nel ToolOutput le informazioni sull’ordine e lo stato «in attesa di pagamento».
  3. L’utente completa il pagamento nella UI (Instant Checkout lo fa direttamente in ChatGPT).
  4. Il provider di pagamento invia un webhook al vostro backend.
  5. Il backend, tramite il DB, cambia lo stato dell’ordine; alla successiva chiamata degli strumenti o a un follow‑up del modello si può già dire onestamente: «Ordine pagato, ecco i dettagli».

Talvolta il backend può innescare un follow‑up in modo indiretto — ad esempio tramite un widget o un’integrazione Realtime che, su segnale dal server, chiama a sua volta sendFollowUpMessage. Ma anche se questo non c’è, il fatto del pagamento è memorizzato da voi e, alla successiva invocazione dello strumento, il backend leggerà il nuovo stato dal DB e restituirà al modello i dati aggiornati per la risposta.

È importante che il webhook sia un punto d’ingresso che vive allo stesso livello del server MCP e usa gli stessi servizi (DB, code, secret). La logica di sicurezza è sostanzialmente la stessa: privilegi minimi, input validati, logging accurato.

10. Errori tipici nel lavoro con webhook e integrazioni esterne

Errore n. 1: assenza di verifica della firma del webhook.
A volte gli sviluppatori si accontentano di un URL «segreto» o di un semplice Bearer my-secret nell’header. Se però quel secret trapela, chiunque può inviarvi webhook, creando ordini, cambiando stati dei pagamenti e facendo qualsiasi cosa. L’approccio corretto — firma crittografica del body (HMAC) e verifica del timestamp. Questo rende la falsificazione molto più complessa che «indovinare un URL».

Errore n. 2: elaborazione pesante all’interno della richiesta del webhook.
Scrivere nell’handler del webhook «creare l’ordine, chiamare due API esterne, generare un PDF, invocare il modello GPT, inviare 5 email» è un modo sicuro per incappare in timeout e retry. Il risultato è che genererete voi stessi duplicati che poi dovrete sbrogliare. Molto più affidabile confermare rapidamente la ricezione dell’evento (2xx), registrarlo nel DB o in una coda ed elaborarlo in background.

Errore n. 3: logica di business non idempotente.
Spesso si vede codice del tipo «a ogni payment_succeeded incrementare il saldo dell’importo». Se il webhook arriva due volte, il saldo raddoppia. Un’altra variante — creare due volte lo stesso ordine o inviare due volte un’email all’utente. L’idempotenza si ottiene tramite un identificatore stabile dell’evento, una tabella degli eventi elaborati, transazioni e operazioni del tipo «impostare uno stato» invece di «aggiungere ancora».

Errore n. 4: assenza di schemi e validazione dei dati del webhook.
Anche un webhook firmato può non essere ciò che vi aspettavate: il provider ha cambiato formato, copiate il JSON dalla documentazione ma nell’ambiente di test il campo si chiama in modo diverso, oppure avete semplicemente sbagliato i tipi. Se elaborate un JSON del genere senza schemi e verifiche, gli errori romperanno silenziosamente gli ordini o causeranno eccezioni a metà catena. L’uso di Zod/JSON Schema in ingresso semplifica la diagnosi e permette di scartare chiaramente gli eventi non validi.

Errore n. 5: logging dei body grezzi dei webhook con PII.
Nell’impeto del debug è facile inserire console.log(rawBody) e dimenticarsene. In produzione questo si trasforma in log pieni di email, indirizzi e altre PII che finiscono su servizi di log esterni. Dal punto di vista della privacy e delle normative (stile GDPR) è un colpo al piede. Meglio implementare subito un PII‑scrub — mascherare i campi sensibili e loggare solo ciò che serve davvero alla diagnostica.

Errore n. 6: mescolare webhook di test e di produzione.
Situazione tipica — lo stesso endpoint riceve eventi sia dall’ambiente di test sia da quello di produzione del provider. Alla fine un pagamento di test cambia all’improvviso lo stato di un ordine reale o viceversa. È più affidabile separare gli URL (ad esempio, /webhooks/commerce/test e /webhooks/commerce/live) o almeno mantenere in configurazione la «modalità» e verificarla in ingresso.

Errore n. 7: dipendenza totale dello scenario ChatGPT da un webhook sincrono.
Talvolta si vorrebbe che, dopo aver invocato lo strumento e creato la sessione di checkout, il modello conoscesse subito l’esito del pagamento. Ma i webhook per definizione sono asincroni e il pagamento può richiedere tempo. Progettare lo scenario come se tutto avvenisse istantaneamente è una cattiva idea. Meglio progettare dialoghi e strumenti in modo che gestiscano correttamente eventi differiti: salvare lo stato dell’ordine, consentire all’utente di tornare alla chat e ottenere le informazioni aggiornate più tardi.

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