Home / Guides / Stripe webhooks for AI agents
Don't grant Stripe access on /success. Verify the webhook.
Ask an agent to “add Stripe Checkout” and you often get this: create a Checkout Session, set success_url to /success, and on that page write paid = true. Two problems. Anyone can open /success. Paying customers can close the tab and never reach it. Stripe’s fulfillment guide treats the webhook as the source of truth for that reason.
This page is the payment rule: verify the signature, fulfill from the event, and survive duplicates. For the rest of a Next.js + Supabase Cursor layout, see Cursor rules for Next.js + Supabase. If the same agent just created a table without RLS, fix that first: Supabase RLS for AI agents.
What /success is for
success_url is a browser redirect after Checkout. You can append {CHECKOUT_SESSION_ID} and retrieve the session to render a receipt. That is display. It is not entitlement.
- The URL is not a secret. A user who knows
/thanksor/success?session_id=cs_test_…can hit it without paying, unless you re-fetch the session and checkpayment_statuson the server. Agents skip that check. - The buyer may never arrive. Tabs close. Mobile browsers drop the redirect. Stripe documents this drop-off explicitly.
- Hosted Checkout can wait up to 10 seconds for your
checkout.session.completedhandler before redirecting. That is a hint: Stripe expects the webhook to finish first.
Rule the agent must follow: the success page reads status. The webhook writes it.
Verify the event, then look at it
Without verification, anyone who can POST to your route can grant access. Stripe’s webhook docs require the raw body, the Stripe-Signature header, and the endpoint secret. Official libraries expose this as constructEvent. If verification fails, return 400 and do nothing else.
import Stripe from "stripe";
import { headers } from "next/headers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const rawBody = await req.text(); // raw bytes, not req.json()
const signature = (await headers()).get("stripe-signature");
if (!signature) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("invalid signature", { status: 400 });
}
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillCheckout(session.id);
}
return new Response("ok", { status: 200 });
}
Three details agents get wrong:
- Raw body.
JSON.parsethen re-stringify changes whitespace and the signature fails, or worse, you skip verify because parse already “worked”. - Per-endpoint secret. Test and live secrets differ. Multiple endpoints have different
whsec_values. Read the secret for this URL from env, not a hardcoded test value. - Return 2xx after you accept the event. A 500 makes Stripe retry. Do heavy work after you have recorded the event id, or queue it, but do not fail the HTTP response because your email provider timed out if you already granted access.
Handle duplicate events
Stripe retries. Your own replay script may run twice. The same Checkout Session can also produce two different events (checkout.session.completed and later checkout.session.async_payment_succeeded for delayed methods). Dedup on event.id and on the business object.
export async function fulfillCheckout(sessionId: string) {
// 1. If this session is already fulfilled, return.
// 2. Mark it processing (unique on sessionId) so a concurrent retry exits.
// 3. Retrieve the session from the API with line_items expanded.
// 4. If payment_status is not paid / no_payment_required, return.
// 5. Grant the entitlement once.
// 6. Mark the session fulfilled.
}
export async function recordStripeEvent(eventId: string): Promise<boolean> {
// Insert event.id into a unique table.
// Return false if the insert conflicts (already seen).
return true;
}
In the route, call recordStripeEvent(event.id) first. If it returns false, respond 200 and stop. Then call fulfillCheckout, which must itself be safe to run twice for the same session.id. Stripe’s endpoint ops guide spells out “processing / processed” marks so two overlapping retries cannot both grant.
Do not use event.created as an idempotency key. Distinct events can share a timestamp, and Stripe does not promise delivery order.
Which events grant access
checkout.session.completed— the session finished. For card payments that is usually paid. For ACH and other delayed methods the session can complete while payment is still processing.checkout.session.async_payment_succeeded— the delayed payment actually landed. Handle both, and letfulfillCheckoutno-op the second time.- Retrieve, don’t trust the snapshot alone if you need line items or a later status. Expand
line_itemson retrieve, then checkpayment_status.
Subscriptions have their own objects (invoice.paid, customer.subscription.updated). Same rules: verify, then idempotent write. Do not chain “I already saw invoice.created so invoice.paid must be next.”
The Cursor / AGENTS.md rule
Scope it to the Stripe routes so it loads when those files are in context.
--- description: Stripe Checkout and webhooks globs: app/api/stripe/**,lib/stripe/** alwaysApply: false --- - Never grant paid access, roles, or downloads from success_url or /success. - Verify every webhook with constructEvent(rawBody, Stripe-Signature, endpoint secret). - Fulfill from checkout.session.completed and checkout.session.async_payment_succeeded. - Dedup on event.id; fulfill at most once per Checkout Session id. - success_url is for display. Retrieve the session there if you render a receipt, but do not write entitlements.
Add a test the agent cannot skip: POST a signed fixture (Stripe CLI stripe trigger checkout.session.completed, or a recorded payload plus its signature) and assert the user is marked paid. POST the same event again and assert the grant count stays one. POST an unsigned body and assert 400 and no write. Put those cases in the definition of done.
Local wiring agents forget
Use the Stripe CLI to forward events to localhost and to print the matching whsec_. That secret is not the live Dashboard secret. Put both in env, never in source. Keep STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET off any NEXT_PUBLIC_ prefix. If the app also talks to Supabase, the webhook handler uses the service-role key on the server; that path bypasses RLS, so keep it to “mark this user paid” and nothing else.
FAQ
Can I grant paid access on the Stripe success URL?
No. success_url is a browser redirect anyone can visit, and paying customers may close the tab before they reach it. Fulfill from a verified webhook, then use /success only to show status.
How do I verify a Stripe webhook?
Read the raw request body and the Stripe-Signature header, then call the official constructEvent helper with your endpoint secret. Return 400 if verification fails. Do not JSON.parse the body first.
Why do Stripe webhooks fire twice?
Stripe retries deliveries and may send the same event more than once. Store processed event IDs and return 200 without doing the work again. Also key fulfillment on the Checkout Session ID so checkout.session.completed and async_payment_succeeded cannot double-grant.
Which events should grant access?
For Checkout, handle checkout.session.completed and checkout.session.async_payment_succeeded. Retrieve the session, check payment_status, then fulfill once per session. Delayed methods such as ACH are not paid on completed alone.
Should the success page retrieve the Checkout Session?
You can retrieve it to render a receipt, but do not write entitlements there. Customers may never land on the page. Stripe’s fulfillment docs treat the webhook as the source of truth.
Building on Next.js + Supabase?
The Supabase + Next.js Agent Kit (DRW-06, $19) includes a Stripe webhook rule that grants access only after a verified webhook, never from the success URL, alongside RLS migration rules, a Claude Code hook that blocks a new table without RLS, @supabase/ssr auth and a /stripe-webhook prompt.
Written against documented conventions as of October 2026; not yet run against a reference project. 14-day refund.
Want the whole drawer?
One-File Agent Rules ($9) is one universal AGENTS.md with a plan gate and a Next.js + Supabase module that includes the webhook rule. The Vibe Starter Rules Pack ($19) adds a fill-in AGENTS.md + CLAUDE.md, 8 scoped Cursor rules (auth, RLS, Stripe webhooks, env, tests, UI), 12 prompts and launch checklists.
Or get the free Starter AGENTS.md by email. One-time purchase, instant download. Packs are written against documented conventions; we have not benchmarked them against a reference project.
Launch week: 30% off any kit with code LAUNCH at Stripe checkout, through Oct 28, 2026. 14-day refund.