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.