1. Webhooks in a ChatGPT App: who calls whom, really
In the classic HTTP world, it’s simple: you’re the client, you make a POST to /api/..., the server responds, everyone’s happy. With webhooks it’s the opposite: an external service initiates an HTTP request to your backend when something happens outside your system.
In the ChatGPT Apps ecosystem this appears in several common scenarios. For example, after creating a checkout via ACP/Instant Checkout, GiftGenius receives a payment_succeeded notification from the payment provider via a webhook. Or a background service that generates preview images for gifts sends you image_ready when rendering finishes. In such cases, ChatGPT and your MCP server have already done their work, the ball is in the third service’s court, and it reports the result to you via a webhook.
The key characteristic: the initiative is outside your system. The request can arrive at any time, and as many times as it wants. Therefore, think of your webhook handler as a potentially most exposed point — that’s where the entire internet can knock.
A small table for contrast:
| Call type | Who initiates | Example in GiftGenius |
|---|---|---|
| Regular API request | You | The MCP server calls the Stripe API |
| Webhook | The outside world | Stripe sends payment_succeeded to you |
2. A simple diagram: where ChatGPT is, where MCP is, and where the webhook is
Schematically, the flow looks like this:
sequenceDiagram
participant User as User in ChatGPT
participant GPT as ChatGPT + model
participant App as GiftGenius (MCP/App)
participant PSP as Payment provider (Stripe/ACP)
User->>GPT: "I want to buy a gift"
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 (event accepted)
App->>DB: mark the order as paid
The first part is regular outbound requests, which you already know how to make. The webhook is the lower part of the diagram, where the payment provider calls you. That’s what we care about today.
3. A basic webhook handler in Next.js (skeleton)
We continue building our learning project GiftGenius on Next.js 16. In the template we have app/ with the UI and app/mcp/route.ts with the MCP server.
It’s logical to put the webhook handler into a separate HTTP route, for example: app/api/webhooks/commerce/route.ts.
The minimal skeleton looks like this:
// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const rawBody = await req.text(); // 1. Read the body as a string
const headers = Object.fromEntries(req.headers); // 2. Take headers
// 3. TODO: signature validation (we'll add it later)
// 4. TODO: JSON parsing and event handling
return new Response("ok", { status: 200 }); // 5. Respond quickly with 2xx
}
There are several important ideas here already.
First, we read the body as text, not immediately with await req.json(). Many providers sign the raw byte stream of the body, and if you parse it (and especially reformat it) before verifying the signature, the signature will no longer match.
Second, we plan for a fast 2xx response. It’s better to move heavy work either to a separate worker or at least into an async function after logging the event. This directly relates to timeouts and retries, which we’ll cover shortly.
4. Webhook signatures: how to tell “Stripe” from “some person with curl”
Let’s recall the TODO from the webhook handler skeleton — “signature validation.” Let’s figure out how to distinguish the real Stripe from “some person with curl.”
The biggest naive assumption is to think that if the URL is complex (/api/webhooks/stripe/super-secret-abc123), no one will find it. URL secrets are essentially security through obscurity: trying to hide behind a complex URL provides very weak protection. The proper line of defense is a cryptographic signature.
Almost all serious providers (Stripe, ACP, many CRMs) compute an HMAC signature over the request body and time, and then put the result into a header. You, as the recipient, do the same and compare. If it’s even slightly different — you discard the request as a forgery.
General recipe:
- You have a webhook secret obtained in the provider’s dashboard and stored in your environment secrets (for example, STRIPE_WEBHOOK_SECRET in Vercel env).
- The provider computes an HMAC over timestamp + '.' + rawBody when sending the request.
- It puts the timestamp and one or more signatures into a header, for example Stripe-Signature.
- In the handler, you take the timestamp, compute your HMAC by the same rule, and compare.
A mini TypeScript example using crypto:
import crypto from "crypto";
function computeSignature(secret: string, payload: string) {
return crypto
.createHmac("sha256", secret) // choose the algorithm
.update(payload, "utf8") // raw body text
.digest("hex"); // hex string
}
Example of signature and event freshness verification:
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 });
}
Note timingSafeEqual — this protects against timing attacks where an attacker tries to guess the signature by measuring comparison duration.
After successful signature verification you can safely call JSON.parse(rawBody) or await req.json(), knowing it came from the real provider.
Additional layers of defense like an IP allowlist (allow requests only from the provider’s addresses) and a dedicated domain for webhooks won’t hurt, but it’s the cryptographic signature that gives you confidence in authenticity.
5. Timeouts, fast responses, and asynchronous processing
Webhooks favour those who respond quickly. Most payment and commerce platforms expect your endpoint to return a 2xx within a few seconds (often up to 10 seconds, sometimes less). If you “think” too long, they consider the call unsuccessful and start retrying.
Naively, this looks like: you verified the signature, hit the DB, called three more external APIs, computed a report, generated a PDF, and only then returned 200 OK. If any of this hangs even slightly, the payment provider will decide the webhook failed and will send it again. As a result, you’ll create the order twice, send the email twice, invoke some GPT tool twice — and run to fix the mess.
The correct pattern is “accept, persist, defer”:
- Verify the signature and basic invariants (event type, required fields).
- Quickly write the event to a table/queue (minimal DB operations).
- Return 2xx.
- Process the event in the background, in a separate worker.
A simplified “semi-correct” handler without a separate queue but with a fast commit:
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); // quick write to the DB
// Here you could dispatch a background job via setImmediate/queue,
// but in this learning example we'll limit ourselves to the write: call without await,
// so that the 200 response returns immediately.
processWebhookEventLater(event).catch(console.error);
return new Response("ok", { status: 200 });
}
Note: we do not call await processWebhookEventLater(...). The handler schedules the task in the background and immediately returns 200 to avoid running into webhook timeouts.
In real production, this is often where a queue appears (for example, a dedicated webhook_jobs table or an external service), and workers carefully drain events without blocking the intake of new ones.
6. Idempotency and deduplication: how not to charge twice
In learning samples people draw perfect arrows: one event → one processing → a happy order. In real life, webhooks arrive like fluffy kittens — in batches and several times in a row.
The reasons are simple: networks are unreliable, timeouts happen, and many providers intentionally resend events until they get a confident 2xx. This is especially important for payments: better to resend payment_succeeded than lose it forever.
Therefore your business logic must be idempotent: processing the same event again should not change the outcome (or at least should not break the system).
A typical pattern:
- The event has a stable identifier, such as event.id or checkout_session_id.
- You store it in a table of processed events and put a unique index on that field.
- On each webhook, first check: if there’s already a record with that id with status “processed,” just return 200 and do nothing.
A mini example using a pseudo ORM:
async function handlePaymentSucceeded(event: any) {
const existing = await db.webhookEvents.findUnique({
where: { providerId: event.id },
});
if (existing?.processedAt) {
return; // already handled
}
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" },
});
});
}
The transaction matters: you mark the event as processed and change the order at the same time. If anything fails in the middle, the transaction rolls back, and on the next webhook retransmission you’ll try again — without duplicate writes.
It’s also a good practice to make the operation itself idempotent, for example:
- “set the order status to PAID” instead of “increase the balance by +100”;
- “create a record if it doesn’t exist” instead of “add another row”.
7. Webhook data validation and PII: a signature isn’t the only filter
Even if the webhook is signed and came from a real service, treat its data with the same suspicion as user input or tool arguments. In the previous lecture we already discussed that schemas and normalization are your firewall.
A schema for an event might look like this (at the TypeScript/Zod level):
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(),
}),
}),
});
In the handler you validate:
const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// then work only with parsed
This protects you from surprises like “the provider changed the format,” “in the test environment the field became nullable,” and so on. If something is off — record the error in logs and return 400; the provider will retry or send an alert later.
Remember PII as well: webhook bodies often contain emails, shipping addresses, and sometimes even pieces of payment data (tokenized). Mask them in logs and don’t send raw bodies to third-party APM/logging services — this is a must-have practice we discussed in the topic on secrets and confidential data.
And you definitely shouldn’t send the full webhook JSON back to ChatGPT as a ToolOutput without filtering — the model should not see everything the payment provider sent, especially if it’s not needed for UX.
8. GiftGenius in practice: a payment webhook for ACP/Instant Checkout
Back to our GiftGenius. In the commerce and ACP module we’ve already covered how the agent creates a checkout session and how Instant Checkout then charges the payment. From the perspective of our backend, the next step is to wait for the order.paid webhook (or checkout.session.completed in Stripe terms) to:
- persist the order status;
- kick off the chain “send an email” / “prepare the shipment”;
- give the agent a confident response “payment succeeded”.
An example of a simple 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") {
// Idempotent handler from the previous section
await handlePaymentSucceeded(event);
}
return new Response("ok", { status: 200 });
}
The verifyCommerceSignature function implements HMAC signature logic similar to what we examined above. In a real project, it makes sense to create a module for each provider (verifyStripeSignature, verifyACPCheckoutSignature) so that schemas don’t get mixed together.
Inside handlePaymentSucceeded you:
- validate the object via a schema (Zod);
- in a transaction, mark the event as processed and update the order;
- optionally enqueue “slow” actions: emails, analytics, additional API calls.
This approach makes the “ACP → webhook → GiftGenius” chain resilient to duplicate events, temporary failures, and odd data.
9. Where webhooks meet MCP, ChatGPT, and tools
At first glance, webhooks seem to live apart from a ChatGPT App: some HTTP route in the backend, and that’s it. In reality, they’re an important part of the overall architecture.
Typically the integration looks like this:
- The MCP tool create_checkout is invoked by the model in ChatGPT.
- The MCP server talks to the payment provider, creates a checkout session, and returns ToolOutput with order info and a “waiting for payment” status.
- The user completes the payment in the UI (Instant Checkout does it right in ChatGPT).
- The payment provider sends a webhook to your backend.
- The backend updates the order status via the DB; on the next tool call or a follow-up from the model you can honestly say: “The order is paid; here are the details.”
Sometimes the backend can trigger a follow-up indirectly — for example, via a widget or a Realtime integration that, upon a server signal, calls sendFollowUpMessage on its own. But even if that’s not present, the fact of payment is stored on your side, and on the next tool call the backend will read the new status from the DB and return updated data to the model for the reply.
Importantly, the webhook is an entry point that lives at the same level as the MCP server and uses the same services (DB, queues, secrets). The security logic is essentially the same: least privilege, validated input, careful logging.
10. Common mistakes when working with webhooks and external integrations
Mistake #1: no webhook signature verification.
Sometimes developers rely on a “secret” URL or a simple Bearer my-secret header. If that secret leaks anywhere, anyone can spam you with webhooks — creating orders, changing payment statuses, and doing anything at all. The correct approach is HMAC of the body and timestamp verification. This makes forgery substantially harder than “guessing a URL.”
Mistake #2: heavy processing inside the webhook request.
Doing “create an order, call two external APIs, generate a PDF, call a GPT model, send 5 emails” inside a webhook handler is a surefire way to hit timeouts and retries. You’ll create your own duplicates and then have to unwind them. It’s much more reliable to quickly acknowledge receipt (2xx), write the event to the DB or a queue, and process in the background.
Mistake #3: non-idempotent business logic.
You often see code like “on each payment_succeeded, increase the balance by the amount.” If the webhook arrives twice, the balance doubles. Another variant is creating the same order twice or sending the user the email twice. Idempotency is achieved via a stable event identifier, a table of processed events, transactions, and operations like “set status” instead of “add more.”
Mistake #4: no schemas and no webhook data validation.
Even a signed webhook may not be what you expect: the provider changed the format, you copied JSON from docs but in the test environment the field is named differently, or you simply made a typing mistake. If you process such JSON without schemas and checks, errors will silently break orders or raise exceptions mid-chain. Using Zod/JSON Schema on input simplifies diagnostics and lets you clearly reject invalid events.
Mistake #5: logging raw webhook bodies with PII.
In the heat of debugging it’s easy to add console.log(rawBody) and forget about it. In production, this turns into logs full of emails, addresses, and other PII sent off to third-party logging services. From a privacy and compliance (GDPR-like) perspective, this is a foot-gun. Implement PII scrubbing up front — mask sensitive fields and log only what’s truly needed for diagnostics.
Mistake #6: mixing test and live webhooks.
A common situation is a single endpoint receiving events from both the provider’s test and live environments. A test payment then unexpectedly changes the status of a real order, or vice versa. It’s more reliable to separate URLs (for example, /webhooks/commerce/test and /webhooks/commerce/live) or at least store a “mode” in config and verify it on input.
Mistake #7: making the entire ChatGPT flow depend on a synchronous webhook.
Sometimes you want the model to know the payment result immediately after calling the tool and creating the checkout session. But by definition webhooks are asynchronous, and payment can take time. Designing the flow as if everything will happen instantly is a bad idea. It’s better to design dialogues and tools so they handle deferred events correctly: persist order state, let the user return to the chat, and get up-to-date information later.
GO TO FULL VERSION