Find which vendor disagrees before changing code
Open the webhook endpoint in Stripe Workbench and read the Event deliveries tab: each event is Delivered, Pending or Failed, with the HTTP status of the last attempt. Then check the host's deployment status and notice emails, and the AI provider's usage and limits pages. Fix the first place where the state is wrong, not the last place it shows up.
Make the endpoint reachable, verified and quick
Point the endpoint at the final URL (no redirects), make sure the route is public and accepts POST, and exempt it from CSRF protection if your framework adds one. Verify the signature against the raw body with that endpoint's own secret, then return 2xx before doing slow work.
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature') ?? '';
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('bad signature', { status: 400 });
}
await enqueue(event);
return new Response(null, { status: 200 });
}
Replay the events you missed
Call List Events with delivery_success=false for the event types your endpoint handles, anchored with ending_before to the last event you processed. Stripe only returns events from the last 30 days. Send replays through the idempotent handler below, because automatic retries may still deliver some of them.
curl -G https://api.stripe.com/v1/events \
-u "$STRIPE_KEY:" \
-d "types[]=invoice.paid" \
-d delivery_success=false
Make every grant idempotent and re-read state
Record each event ID under a unique constraint in the same transaction as the grant, and skip the grant when the insert returns no row. For subscriptions, retrieve the subscription after invoice.paid and extend access only when its status is active. Revoke access when the status becomes canceled or unpaid, and notify the customer on past_due.
create table processed_events (event_id text primary key);
-- in the same transaction as the grant:
insert into processed_events (event_id) values ($1)
on conflict do nothing
returning event_id;
-- no row returned: already handled, skip the grant
Match the endpoint's API version to your SDK
Webhook events use the API version set when the endpoint was created, or the account default if none was set. Since stripe-node v12 the SDK pins its own version for requests, so an upgrade can leave the endpoint and your code on different event shapes. Create an endpoint with the version your SDK pins, test it alongside the old one, and disable the old one once events process cleanly.
Watch billing and quota state for every vendor in the chain
Set a spend amount with alerts on the host (Vercel notifies at 50%, 75% and 100%) and send those alerts to an inbox someone reads. Spend checks run every few minutes, so a pause or a cap can land after the threshold. Add an external check against a production route that touches the database, so an expired trial or a paused project pages you before a user finds it.
Put a limit and a price on every AI call, and classify errors by status
Cap the tool-call loop at a fixed number of steps per request and a call rate per user, and log each call with user, model and cost. Route errors by status and code: billing failures alert you and are never retried, rate limits honor Retry-After, and only 5xx errors back off.
const BILLING = new Set([
'credit_balance_exhausted',
'organization_spend_limit_exceeded',
'project_spend_limit_exceeded',
'quota_for_entity_exceeded',
]);
export function classify(status: number, code?: string) {
if (status === 402 || (code && BILLING.has(code))) return 'billing';
if (status === 401 || status === 403) return 'auth';
if (status === 429) return 'rate';
if (status >= 500) return 'retry';
return 'fail';
}