Skip to main content

Stripe troubleshooting

"Stripe webhook signature mismatch"​

The marketplace returns 400 with a signature error. Causes:

  • STRIPE_WEBHOOK_SECRET not set, or wrong value.
  • Webhook controller isn't reading raw body. Verify @Req() req: RawBodyRequest<Request> and req.rawBody is used in constructEvent — not req.body.
  • Some intermediary modified the body (a buggy WAF, a proxy rewriting JSON, a Node middleware re-stringifying).

Test:

stripe listen --forward-to https://api.fleapo.ai/webhooks/stripe
stripe trigger checkout.session.completed

If stripe listen shows 200, the issue is environment-specific.

"Webhook succeeds in Stripe but DB doesn't update"​

Check stripe_events:

SELECT type, processed_at, error_message
FROM stripe_events
ORDER BY received_at DESC
LIMIT 10;

If processed_at is NULL and error_message is set, the handler threw. Read the error.

Common handler failures:

  • Tier row for stripePriceId missing — happens if a Stripe Price was created outside the marketplace.
  • metadata.userId absent on the Checkout Session — happens when you craft the session yourself instead of going through POST .../checkout.

After fixing the underlying cause, you can replay events by clearing error_message and processed_at, then re-dispatching. Build scripts/retry-stripe-events.ts if you don't have it.

"Subscription stuck in incomplete"​

The user started Stripe Checkout but 3DS / SCA failed. Stripe's customer.subscription.created fires with status: 'incomplete'. After a few retries, you get either customer.subscription.updated (status active) or incomplete_expired.

Watch for the next event before assuming the user is stuck.

"Stripe says subscription is active but local says past_due"​

Webhook drift. Either:

  • A customer.subscription.updated event was missed (network outage, marketplace down).
  • An event errored and error_message is set.

Run reconciliation:

const sub = await stripe.subscriptions.retrieve(localSub.stripeSubscriptionId);
// Compare sub.status, sub.current_period_end, sub.cancel_at_period_end to local

Build into a scripts/reconcile-stripe.ts and run nightly until events stay caught up.

"Free tier checkout creates the sub but Stripe doesn't know"​

Correct — free tiers bypass Stripe entirely. There's no Stripe Customer or Subscription. Don't go looking for one. The marketplace row carries stripeSubscriptionId: null.

If the user upgrades to a paid tier later, a new Stripe Subscription is created at that time and the row is updated.

"Old Stripe Product still listed in our admin UI"​

Pricing tier deletion archives the Stripe Product (sets active: false) but the row is soft-deleted, not removed. The admin UI may still surface archived tiers depending on its filter. If you want to hard-remove:

DELETE FROM agent_pricing_tiers WHERE id = '<id>' AND <constraint that it's archived>;

Then archive any unused Stripe Products in the dashboard.

"Multiple active subscriptions for one user/agent"​

The partial unique index on user_agent_subscriptions(userId, agentId) WHERE status IN ('active','trialing') should prevent this. If you see it, the index is missing in your environment — check migrations applied cleanly:

SELECT indexname, indexdef FROM pg_indexes
WHERE tablename = 'user_agent_subscriptions';

If absent, generate and apply the migration that adds it.

"Stripe Checkout success_url doesn't carry session_id"​

You omitted {CHECKOUT_SESSION_ID} from the success_url. Add it:

success_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`

Stripe replaces the literal {CHECKOUT_SESSION_ID} at redirect time. Without it, your post-checkout page can't fetch session details to confirm the purchase.