pealdevDOCS
Back to peal.dev

STRIPE STARTER · BUGS WE ALREADY HIT

Bugs we already hit

The edge cases that cost us a weekend so they don't cost you one. Honest list, growing every release.

"No signatures found matching the expected signature"

Means signature verification failed. Three causes, in order of how often we cause them ourselves: (1) STRIPE_WEBHOOK_SECRET is wrong or stale — re-paste from the active `stripe listen` session or the prod endpoint. (2) Something between Stripe and your route is mutating the body — a CDN, a body-parser middleware, a logging proxy. (3) You're sending the wrong route the wrong secret (CLI secret to prod, prod secret to dev).

"Unknown price ID" at checkout

The Server Action allowlists against src/config/pricing.ts. Either the price ID is still a placeholder (price_REPLACE_*), or you typed it manually instead of pasting the real ID from Stripe. Always copy from dashboard.stripe.com/prices.

Webhook fires twice (or three times)

Genuinely happens. Network blips, Stripe retries on slow handlers, occasional duplicates. Build idempotent handlers: check event.id (or a deterministic key per subscription state) before writing. The template doesn't ship a dedupe table — your DB schema is yours — but add one before you launch.

Subscription stayed "active" after the user clicked cancel

That's correct behavior — they paid through end-of-period. .updated fires with cancel_at_period_end=true (use this for UI: "cancels on Nov 5"). .deleted fires when the period actually ends — that's when you revoke access. Don't revoke on the .updated event unless status flips to "canceled".

"Rate limit exceeded" in dev

You set UPSTASH_REDIS_REST_URL while debugging and forgot to remove it. The dev limiter is the same as prod — 10/60s on checkout. Either remove the Upstash vars in dev, or wait 60 seconds, or bump the limit in src/lib/rate-limit.ts.

TypeScript: "object is of type Object {}"

You touched event.data.object without casting. Stripe's TS types don't narrow it. In the webhook switch this is fine because the case label guarantees the shape — cast there. Anywhere else, use getCustomerId / getResourceId or pull the narrowed type off the case.

Successful test payment but no webhook

`stripe listen` isn't running, or it's pointing at the wrong port. Check the CLI window — it logs every forwarded event. If it's silent during a checkout, the forwarder isn't connected. Restart it, re-paste the new whsec_.

Customer Portal: "You must set up the portal first"

Stripe-side configuration. Open dashboard.stripe.com → Settings → Billing → Customer portal → Activate. Stripe needs you to confirm which features customers can use before serving the portal.

ESLint: "as assertion is forbidden"

Project rule — no `as` assertions in src/ except tests and the webhook dispatch switch. If you're tempted, the answer is usually a helper. getCustomerId for customer fields, getResourceId for subscription/payment_intent fields. Add a new helper to src/lib/stripe.ts if you need a third one.

Success page shows "Invalid Session" right after a payment

Either the session_id query param failed Zod (rare — Stripe sends it correctly), the stripe.checkout.sessions.retrieve() call threw (network blip), or session.status is "open" (3DS in progress, async payment pending). The view is a deliberate safe fallback — log the error in your error.tsx, refresh, the second load usually works.