Setting Up Stripe With AI? 7 Problems Your AI Assistant Can't Solve For You
AI can write your Stripe webhook handler, but it can't navigate the Dashboard, find your signing secret, or fix a wrong endpoint URL. Here are the 7 config problems you'll hit and exactly where to fix them.
Rod
Founder & Developer
AI tools like Cursor, Claude, and Copilot are great at generating Stripe webhook handlers. The code they produce usually works. The problem is everything around the code — the Stripe Dashboard configuration, the environment variables, the endpoint URLs, the API keys. AI can't click buttons in your Stripe Dashboard. It can't check if your env vars are set in Vercel. It can't look at the webhook delivery logs and tell you the response was a 307.
We built Data Hogo's entire payment system with AI assistance. The code was solid from day one. We still broke payments 7 different ways — all configuration problems that no AI could have prevented or diagnosed.
Here's each problem, the exact error you'll see, and where to fix it.
Problem #1: Wrong Webhook Endpoint URL
This is the most common Stripe webhook failure, and the one AI tools will never catch because it's a Dashboard configuration issue.
What happens: You register a webhook endpoint in Stripe Dashboard. But the URL doesn't exactly match what your server serves. Common mismatches:
https://datahogo.com/...vshttps://www.datahogo.com/...(www redirect)https://myapp.com/api/webhookvshttps://myapp.com/api/webhook/(trailing slash)https://myapp.com/api/webhooks/stripevshttps://myapp.vercel.app/api/webhooks/stripe(wrong domain)
What you'll see in Stripe Dashboard:
Go to Developers > Webhooks > select your endpoint > Recent deliveries. Click on any failed delivery. The response will look like:
{
"redirect": "https://www.datahogo.com/api/webhooks/stripe",
"status": "307"
}HTTP status code: 307 (or 301, 302). Stripe does not follow redirects. Any non-2xx response = failed delivery.
How to fix it:
- Open your production app in a browser and look at the URL bar — does it have
www? Does it have a trailing slash? - Go to Stripe Dashboard > Developers > Webhooks
- Click your endpoint, then Update details
- Change the URL to exactly match your production URL
Your AI assistant wrote a perfectly good handler. It just never received a single request because the URL was wrong.
Problem #2: Where to Find Your API Keys (The Dashboard Treasure Hunt)
AI will generate code with process.env.STRIPE_SECRET_KEY and process.env.STRIPE_WEBHOOK_SECRET, but it can't tell you where to get those values. Here's the exact navigation:
STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
Stripe Dashboard > Developers > API keys
| Key | Starts with | Use in |
|---|---|---|
| Publishable key | pk_live_ or pk_test_ |
Frontend (safe to expose) |
| Secret key | sk_live_ or sk_test_ |
Backend only (never expose) |
Click "Reveal live key" or "Reveal test key" to copy. If you've never revealed it, you may need to re-create it.
STRIPE_WEBHOOK_SECRET
Stripe Dashboard > Developers > Webhooks > click your endpoint > Signing secret > Reveal
Starts with whsec_.
There are THREE different webhook secrets:
| Environment | Where to get it | Notes |
|---|---|---|
| Local dev | Run stripe listen --forward-to localhost:3000/api/webhooks/stripe — the CLI prints a whsec_ secret |
Changes every time you restart the CLI |
| Test mode | Stripe Dashboard (toggle to Test mode) > Webhooks > endpoint > Signing secret | Persistent |
| Production | Stripe Dashboard (Live mode) > Webhooks > endpoint > Signing secret | Persistent |
Mixing these up always results in a 401 Invalid signature with no other useful information. If your webhook works locally but fails in production, this is almost certainly why.
Problem #3: Environment Variables Not in Production
Your AI generated the code. You tested locally. It works. You deploy to Vercel. Every webhook returns 401.
Why: You added STRIPE_WEBHOOK_SECRET to .env.local but never added it to your hosting provider.
What you'll see in Stripe Dashboard: Every delivery shows HTTP status 401 with response "Invalid signature".
How to fix it in Vercel:
- Vercel Dashboard > your project > Settings > Environment Variables
- Add
STRIPE_WEBHOOK_SECRETwith the value from Stripe Dashboard (live mode) - Make sure "Production" is checked (not just Preview/Development)
- Redeploy — env vars don't apply to existing deployments
Full list of Stripe env vars you need in production:
| Variable | Required | Where to get it |
|---|---|---|
STRIPE_SECRET_KEY |
Yes | Developers > API keys > Secret key |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
Yes | Developers > API keys > Publishable key |
STRIPE_WEBHOOK_SECRET |
Yes | Developers > Webhooks > endpoint > Signing secret |
If you're not sure which env vars you've accidentally left out, this happens more often than you'd think. Our env file exposure guide covers why env handling is a constant source of production incidents.
Problem #4: API Version Mismatch — RangeError: Invalid time value
This is the sneakiest one. Everything works fine for weeks, then one day your webhook crashes with a RangeError and you have no idea why.
What happens: Stripe evolves its API. Your code (probably AI-generated) was written for one API version, but Stripe sends webhook events using the version configured on your Dashboard account. When fields move between objects in a new version, your code reads undefined where it expected data.
Real example we hit:
Our code used API version 2024-12-18.acacia:
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
});Stripe's Dashboard was on 2025-10-29.clover. In this version, current_period_start and current_period_end moved from the Subscription object to SubscriptionItem (inside items.data[0]).
Our handler did:
// This worked for months, then suddenly broke
const periodStart = new Date(subscription.current_period_start * 1000).toISOString();After the API version change:
subscription.current_period_startisundefinedundefined * 1000=NaNnew Date(NaN).toISOString()throwsRangeError: Invalid time value
The fix: Read from both locations:
const firstItem = subscription.items?.data?.[0] as any;
const periodStart =
(subscription as any).current_period_start ?? firstItem?.current_period_start;
const periodEnd =
(subscription as any).current_period_end ?? firstItem?.current_period_end;
if (periodStart) {
const startDate = new Date(periodStart * 1000).toISOString();
// use startDate...
}How to check your API version:
- Your code: look at the
apiVersionin your Stripe client initialization - Your Dashboard: Stripe Dashboard > Developers > Overview shows "API version" at the top
- Webhook events: every event payload includes
"api_version"— check a recent delivery
If they don't match, you'll eventually hit breaking changes. AI won't catch this because it doesn't know what API version your Dashboard is on.
Problem #5: Not Logging Webhook Errors
AI-generated webhook handlers usually have basic error handling, but they rarely log enough detail to debug in production.
What AI typically generates:
try {
event = stripe.webhooks.constructEvent(body, sig!, secret);
} catch {
return new Response("Invalid signature", { status: 401 });
}This catches the error but tells you nothing. Was the secret wrong? Was the body already parsed? Was the header missing?
What you actually need:
try {
event = stripe.webhooks.constructEvent(body, sig!, secret);
} catch (err) {
console.error("[stripe-webhook] Signature verification failed:",
err instanceof Error ? err.message : err
);
return new Response("Invalid signature", { status: 401 });
}Common error messages from Stripe and what they mean:
| Error message | Cause |
|---|---|
No signatures found matching the expected signature for payload |
Wrong STRIPE_WEBHOOK_SECRET or body was parsed with req.json() before verification |
Timestamp outside the tolerance zone |
Clock sync issue or event is very old |
Webhook payload must be provided as a string |
You passed parsed JSON instead of raw text |
Same goes for the main handler — wrap everything in a try/catch and log with context:
try {
await handleStripeEvent(event);
} catch (err) {
console.error("[stripe-webhook] Processing failed:", {
eventId: event.id,
eventType: event.type,
error: err instanceof Error ? err.message : err,
});
// Add Sentry if you have it
Sentry.captureException(err, {
extra: { eventType: event.type, eventId: event.id },
});
return new Response("Processing error", { status: 500 });
}If your security posture could use a second opinion, scan your repo with Data Hogo — we flag missing error handling patterns in webhook routes among other things.
Problem #6: Reading Stripe's Webhook Logs
When something goes wrong, the answer is almost always in the Stripe Dashboard logs. But most developers don't know where to look or what the status codes mean.
Where to check:
Stripe Dashboard > Developers > Webhooks > select your endpoint > Recent deliveries
Each delivery shows:
- HTTP status code — the response from your server
- Response body — what your server returned
- Request body — the full event payload Stripe sent
What the status codes mean:
| Status | What happened | Where the problem is |
|---|---|---|
| 200 | Success | Nothing — it worked |
| 307 (or 301, 302) | Your URL redirected | Dashboard: wrong endpoint URL. Fix: update the webhook URL |
| 401 | Signature verification failed | Missing or wrong STRIPE_WEBHOOK_SECRET in your env |
| 404 | Route not found | Wrong path in the endpoint URL, or the route doesn't exist in your deployment |
| 500 | Your handler crashed | Unhandled exception in your code — check your server logs |
| Timeout | Handler took too long | Your handler is doing too much work synchronously |
Where to find individual events:
Stripe Dashboard > Developers > Events
Here you can filter by event type, see all delivery attempts, and Resend events that failed. After you fix a bug, come here and resend the failed events one by one.
AI can write your handler code, but it can't read these logs for you. When something breaks, this is where you start.
Problem #7: Stripe's Retry Behavior (What Happens After Failure)
When a webhook delivery fails, Stripe doesn't just give up. It retries with exponential backoff over 3 days:
- Immediately
- ~5 minutes later
- ~30 minutes
- ~2 hours
- ~5 hours
- ~10 hours
- (continues spacing out)
What this means for you:
- Don't panic. You have 3 days to fix a broken webhook before Stripe stops trying.
- Don't deploy a half-fix. Take the time to find the actual root cause.
- After fixing, resend failed events from Developers > Events > select event > Resend.
- If Stripe disabled your endpoint (after 3 days of failures), re-enable it from Developers > Webhooks > select endpoint.
Stripe also sends you an email when a webhook endpoint starts failing consistently. Check your inbox — we got ours with the subject "Action required: webhook endpoint is failing" with the exact failure count and date.
Complete Webhook Handler Template
Here's a production-ready Next.js App Router webhook handler that handles all 7 problems:
// app/api/webhooks/stripe/route.ts
import type Stripe from "stripe";
import * as Sentry from "@sentry/nextjs";
import { constructWebhookEvent } from "@/lib/stripe/client";
export async function POST(req: Request) {
// 1. Read raw body — NEVER use req.json() (breaks signature verification)
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) {
return Response.json({ error: "Missing signature" }, { status: 401 });
}
// 2. Verify signature — LOG the error, don't swallow it
let event: Stripe.Event;
try {
event = constructWebhookEvent(body, signature);
} catch (err) {
console.error("[stripe-webhook] Signature verification failed:",
err instanceof Error ? err.message : err
);
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
// 3. Global try/catch — never let an unhandled exception return 500
try {
await handleStripeEvent(event);
} catch (err) {
console.error("[stripe-webhook] Unhandled error:", {
eventType: event.type,
eventId: event.id,
error: err instanceof Error ? err.message : err,
});
Sentry.captureException(err, {
extra: { eventType: event.type, eventId: event.id },
});
return Response.json({ received: true, error: "Processing error" });
}
return Response.json({ received: true });
}
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const plan = session.metadata?.plan;
if (!userId || !plan) break;
// activate subscription...
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
// 4. API version safe — handles field migration between versions
const firstItem = subscription.items?.data?.[0] as any;
const periodStart =
(subscription as any).current_period_start ?? firstItem?.current_period_start;
const periodEnd =
(subscription as any).current_period_end ?? firstItem?.current_period_end;
if (periodStart && periodEnd) {
const startDate = new Date(periodStart * 1000).toISOString();
const endDate = new Date(periodEnd * 1000).toISOString();
// update subscription periods...
}
break;
}
case "customer.subscription.deleted": {
// downgrade user...
break;
}
case "invoice.payment_failed": {
// mark subscription as past_due...
break;
}
}
}Pre-deployment checklist:
- Webhook URL in Stripe Dashboard matches your exact production URL (including www)
-
STRIPE_SECRET_KEYis in your hosting provider's env vars (not just .env.local) -
STRIPE_WEBHOOK_SECRETis in your hosting provider's env vars (live mode secret, not CLI secret) - You've tested a webhook delivery in the Stripe Dashboard and seen a 200 response
- Error monitoring (Sentry or similar) is wired up to catch webhook errors
Frequently Asked Questions
Why is my Stripe webhook returning 307?
Your endpoint URL in the Stripe Dashboard doesn't match your production URL. Stripe does not follow redirects. Go to Developers > Webhooks, click on a failed delivery, and look at the response — it will show the redirect URL. Update your endpoint URL to match exactly.
Where do I find my Stripe webhook signing secret?
Stripe Dashboard > Developers > Webhooks > select your endpoint > Signing secret > Reveal. It starts with whsec_. Remember: local CLI, test mode, and live mode all have different secrets.
Why does my Stripe webhook work locally but fail in production?
Almost always missing env vars. STRIPE_WEBHOOK_SECRET is in your .env.local but not in Vercel/Railway/whatever you deploy to. Also check that you're using the production signing secret (from the Dashboard), not the CLI secret from stripe listen.
What causes RangeError: Invalid time value in a Stripe webhook?
API version mismatch. Fields like current_period_start moved between objects in newer Stripe API versions. Your code reads undefined, multiplies by 1000 to get NaN, and new Date(NaN).toISOString() throws. Read from both the old and new locations with a ?? fallback.
How do I resend a failed Stripe webhook event?
Stripe Dashboard > Developers > Events > find the event > Resend. You can resend individual events after fixing your endpoint. Stripe retries automatically for 3 days before giving up.
What Stripe API keys do I need and where are they?
Three keys: (1) STRIPE_SECRET_KEY — Developers > API keys > Secret key (starts with sk_live_). (2) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — same page (starts with pk_live_). (3) STRIPE_WEBHOOK_SECRET — Developers > Webhooks > endpoint > Signing secret (starts with whsec_). Never expose the secret key in frontend code.
Related Posts
How to Secure a Vibe Coded App Before Going to Production
Step-by-step checklist for securing an AI-built app before launch. Secrets, auth, headers, dependencies, and database permissions — what to check and how to fix it.
Security Headers and SEO: How Missing Headers Hurt Your Rankings
Security headers don't just protect users — missing them actively hurts your Google rankings through bounce rates, Safe Browsing flags, and broken page signals.
OWASP A09 Logging and Monitoring Guide
OWASP A09 is why breaches go undetected for 204 days on average. Learn what to log, what never to log, and how to fix the silent failures in your app.