A Stripe payment goes through, but no record shows up on your server. You investigate and find that your webhook endpoint has been returning 500 for days, with Stripe quietly retrying in the background. Anyone running a SaaS on the side has hit this kind of "events arrived, but nothing happened" situation at least once. Webhooks are a quietly dangerous part of the stack — small implementation mistakes on the receiving side surface much later as missed payments or inconsistent state.
In this guide, I walk through patterns for building webhook endpoints that don't drop events, using Antigravity's AI agents as a development companion. The examples use Next.js (App Router) on Cloudflare Workers, but the principles port cleanly to any stack.
Why developing webhooks in Antigravity actually feels different
Webhooks are notoriously hard to develop locally. The Stripe CLI lets you fire test events, but verifying signature handling, idempotency, and retry behavior simultaneously is a real grind. I used to do this work in VS Code, but after switching to Antigravity, asking the agent something like "build a handler for checkout.session.completed that records an idempotency key in KV" returns the implementation along with signature verification tests, which raises the bar on design quality.
What helped most was using the Manager Surface to keep a record of agent work while exploring multiple verification patterns in parallel. Comparing "track idempotency in a Set" against "track it in KV" by having both implementations generated and side-by-side is the kind of design exploration that's hard to do alone.
Three requirements every webhook endpoint must meet
Before writing code, let me lay out what "doesn't drop events" actually means.
First, response time. Stripe retries for up to three days until it gets a 2xx, but anything past 30 seconds counts as a timeout. Don't do heavy work inside the endpoint itself.
Second, signature verification. Without verifying the signature header, anyone can forge events. This is the security foundation, not optional.
Third, idempotency. Stripe, GitHub, and Slack all occasionally redeliver the same event due to network conditions. Your handler must produce the same outcome whether an event arrives once or three times.
A minimal Stripe webhook handler (Next.js + Cloudflare Workers)
Here's the implementation. I'm writing it as a Next.js Route Handler, but it runs on Cloudflare Workers, so note the use of Stripe.createSubtleCryptoProvider().
// src/app/api/webhook/stripe/route.ts
import Stripe from "stripe";
import { getCloudflareContext } from "@opennextjs/cloudflare";
export const runtime = "edge";
export async function POST(request: Request) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-10-28.acacia",
});
// Important: use the async variant on Cloudflare Workers
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
// Get the raw body before parsing
const body = await request.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
undefined,
Stripe.createSubtleCryptoProvider()
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// Idempotency check (covered below)
const { env } = getCloudflareContext();
const idempotencyKey = `webhook:stripe:${event.id}`;
const existing = await env.KV.get(idempotencyKey);
if (existing) {
// Already processed — return 200 to stop retries
return new Response("Already processed", { status: 200 });
}
// Keep the actual handler in a separate function
await handleEvent(event, env);
// Mark complete (24-hour TTL)
await env.KV.put(idempotencyKey, "1", { expirationTtl: 86400 });
return new Response("OK", { status: 200 });
}The key detail here is using stripe.webhooks.constructEventAsync through a Subtle Crypto Provider. The synchronous constructEvent you see in most Node.js examples does not work on Cloudflare Workers. I lost two hours to this one.
Three traps that break signature verification
Here are the issues that come up most often when implementing this.
The first trap is calling JSON.parse on the body before verifying the signature. Stripe computes signatures over the raw bytes; once you parse, verification will always fail. Use request.text() to keep the raw string and pass it directly to constructEventAsync.
The second trap is mixing up webhook secrets. The Stripe dashboard issues separate secrets for "local development" and "production" deliveries. When you're running the Stripe CLI relay, you need the whsec_... it prints, not the production secret. Using the production secret for local testing leads to the strangest failure mode: signatures look like they validate, but events never arrive.
The third trap is body encoding. Cloudflare Workers requests are UTF-8, but a misconfigured proxy in front can introduce a BOM. Comparing the length of request.text() against the dashboard's payload length is the fastest way to spot this.
A KV-backed idempotency pattern
The simplest robust idempotency strategy is to key off the event.id that Stripe and GitHub already emit. Using Cloudflare KV from the example above:
async function ensureIdempotent(
env: Env,
eventId: string,
source: "stripe" | "github" | "slack"
): Promise<{ alreadyProcessed: boolean }> {
const key = `webhook:${source}:${eventId}`;
const existing = await env.KV.get(key);
if (existing) {
return { alreadyProcessed: true };
}
// Write a "processing" marker first so retries can recover from crashes
// Overwrite to "done" only after the actual work succeeds
await env.KV.put(key, "processing", { expirationTtl: 600 });
return { alreadyProcessed: false };
}The point I want to highlight is separating "in-progress" markers from "completed" markers. If you only set a single marker upfront and the handler crashes, the event is stuck "in progress" forever. Overwriting on success means a TTL'd retry can pick it up cleanly.
If you need long-term retention, switch to D1 or Postgres — but for the question "did the same event ID arrive twice in the last 24 hours?", KV is more than enough. The eventual consistency of Cloudflare KV doesn't bite in this use case.
A testing workflow inside Antigravity
The most efficient local testing setup combines the Stripe CLI relay with Antigravity's terminal integration.
# Terminal 1: Next.js dev server
npm run dev
# Terminal 2: Relay Stripe webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhook/stripe
# Terminal 3: Trigger a test event
stripe trigger checkout.session.completedAntigravity lets you tile multiple terminals side by side, so you can watch all three streams simultaneously. Asking the agent "given the response log for this checkout.session.completed, did signature verification succeed?" will get you a read of the log, plus likely root causes if it failed.
GitHub webhooks work the same way using gh webhook forward to ship live repository events to your localhost. Slack doesn't have an official local relay tool, so Cloudflare Tunnel or ngrok is the practical option there.
Wrapping up: one step you can take today
Webhook event drops rarely show up at launch — they surface once your traffic grows. Restructuring early into the four-stage pattern (verify signature → record idempotency → run business logic → mark complete) makes it much easier to add monitoring or change retry strategy later.
A concrete next step: open one of your existing webhook endpoints in Antigravity and ask the agent "audit whether this endpoint is idempotent." A ten-minute review often catches issues before users do.
For related reading, Stripe SaaS Implementation with AgentKit in Antigravity and Edge Caching and Rate Limiting with Cloudflare KV round out the broader payment infrastructure design.