Troubleshooting guide

Paid users still locked out? Webhooks, paused hosting and AI bills

A customer has paid, or a deploy has shipped, and production behaves as if neither happened. Usually two vendors disagree about money or quota: the payment provider, your database, the host, or the AI provider. This pattern appears in 45 verified builder reports in our data, across payment webhooks, hosting pauses and AI provider cost or quota errors.

45 verified cases15 min read Updated 8 Oct 2026How we collect cases

What you'll see

  • A customer's payment shows as succeeded in Stripe, but the app still treats them as a free user.
  • The webhook endpoint shows failed deliveries for days, or Stripe emails you about a failing endpoint.
  • Logs show 'Webhook signature verification failed' after a deploy or an SDK upgrade.
  • Production returns 503 DEPLOYMENT_PAUSED (on Vercel) while the billing page looks healthy, or deploys stay blocked after you add credit.
  • AI calls return 402, 403 or 429 in production with a key that works locally, and the app shows only a generic failure.
  • Request counts or AI cost spike because a model keeps calling the same tool, or token cost stays low while a large share of tool calls fail.

Why it happens

Cause 1

The webhook is missing, unverified or misrouted

Stripe treats a redirect (any 3xx) as a failed delivery. It also records 4xx for access restrictions (401, 403, 405) or a missing route (404), and a timeout when the handler does slow work before replying. Signature checks fail when the endpoint secret is wrong (the secret printed by stripe listen differs from the Dashboard endpoint's secret) or when middleware parses the body before verification, for example express.json() registered ahead of the webhook route. A health check on another route tells you nothing about any of this.

Cause 2

Retries ran out before anyone looked

In live mode Stripe retries with exponential backoff for up to three days. After that the event is not redelivered automatically, so a grant that depended on it never happens. The event itself stays retrievable through the List Events API for 30 days. A failing endpoint also triggers an email to the account, which can land in an inbox nobody reads.

Cause 3

Grants are not idempotent and ignore ordering

Stripe can deliver the same event more than once, can generate two separate Event objects for one occurrence, and does not guarantee delivery in the order events were created. A handler that adds credits on every delivery double-grants. One that assumes invoice.paid arrives after the subscription update can grant against stale state. Stripe's guidance is to track event IDs, and after invoice.paid to retrieve the current subscription and check that its status is active before extending access.

Cause 4

Another vendor's billing state is blocking you

On Vercel, a 503 DEPLOYMENT_PAUSED has documented causes: Spend Management with Pause Production Deployments reached, an account not in good standing, exceeded limits or quotas, and fair-use violations. Vercel's pause email names the reason. Raising the spend amount does not unpause anything: each project has to be resumed individually in the dashboard or through the REST API. Other hosts may have similar states. Read the notice, then clear the state at its source.

Cause 5

AI calls are metered after the fact and errors hide the real status

Provider errors that look alike mean different things. OpenAI returns 429 both for rate limits (retry later) and for exhausted credit or spend limits (credit_balance_exhausted, organization_spend_limit_exceeded, project_spend_limit_exceeded), and its docs say retrying billing, spend or quota errors will not restore access. Anthropic uses 402 billing_error and 403 permission_error, returns 400 when an organization or workspace spend limit you set is reached, and sends a 429 with no retry-after header for a tier spend cap. Vercel AI Gateway returns 402 with type quota_for_entity_exceeded when a budget is exhausted, but its docs note the SDK error class can differ from the HTTP status. The official Anthropic SDK also retries twice by default, so a retry loop of your own multiplies the calls.

How to fix it

  1. 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.

  2. 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 });
    }
  3. 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
  4. 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
  5. 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.

  6. 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.

  7. 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';
    }

Check it's fixed

  • Send a test-mode payment end to end: the Event deliveries tab shows Delivered with a 200, the user's access changes, and cancelling the subscription removes it.
  • Resend the same event from Workbench and confirm the processed_events table gains no second row and the credit balance does not change.
  • Set a $1 budget on a throwaway AI key, exhaust it in staging, and confirm you get an alert and the app shows a specific message rather than a generic failure.
  • Trigger a spend alert and a failed production health check on purpose, and confirm both reach a person within minutes.

Fix it with Gemmein

On Gemmein, a relay turns a provider's signed webhook into access. It verifies the event, makes each action idempotent per event, runs one customer's payment events in the order the payments happened, retries failures and shows every run in the owner's dashboard with a replay button. Stripe stays built in: the built-in Stripe path grants access and writes receipts without a relay.

  1. Declare the webhook as a receiver relay

    A relay file in gemmein/relays/<name>.json with a receiver trigger takes the provider's signed webhook at POST /hooks/<appId>/<name> and checks it with the named verify scheme. Its map resolves the person and dedupes on event_id, and its actions, such as fulfil_product or grant_access, carry out the grant.

    {
      "name": "gocardless-paid",
      "trigger": {
        "kind": "receiver",
        "verify": { "scheme": "shared_token" },
        "map": {
          "event_id": "events.0.id",
          "event_type": "events.0.action",
          "person_email": "events.0.details.customer_email",
          "payment_id": "events.0.links.payment"
        },
        "when": { "event_type": "confirmed" }
      },
      "actions": [
        { "type": "fulfil_product", "product": "Starter pack", "ref": "{{mapped.payment_id}}" }
      ]
    }
  2. Map paid_at whenever the provider sends a payment time

    Payment events for one customer run in the order the payments happened, across all payment relays. The order uses the mapped paid_at, or else the time the event arrived, and a refund waits while the same customer's earlier payment event is queued, running or retrying. Without paid_at, a payment for an address whose owner deleted their account grants nothing and shows on the Problems page.

  3. Read failed runs in the Relays room and replay them

    A failed run retries after 1 min, 5 min, 30 min, 2 h, 8 h and 16 h, and then it is dead: the owner is alerted and can replay it. Without an edit, a retry skips the steps that already succeeded, and a grant already done for that event is not repeated. The Problems page lists events held for more than 60 seconds behind a paused or retrying payment relay.

  4. Start AI work as runs that hold credits

    A job tool reserves credits at the tool's ceiling when the run is created and settles at what the provider metered when it ends; a failed or cancelled run releases the hold. The credits_exhausted (402) message names the ceiling, and sending the same key on the same tool returns the same run.

    const run  = await g.runs.start("poster", { prompt }, { key: jobId })
    const done = await g.runs.watch(run.id, { onUpdate: r => show(r.progress) })
    if (done.status === "succeeded") img.src = done.result.files[0].url

Questions

How long does Stripe keep retrying a failed webhook?

In live mode Stripe retries with exponential backoff for up to three days. In a sandbox it tries three times within a few hours. After that, use the List Events API to fetch events from the last 30 days and process them yourself.


Why does signature verification fail when the secret looks right?

The usual causes are using the secret from stripe listen against a Dashboard endpoint (or the reverse), or a framework changing the body before you verify it. In Express, register the webhook route before express.json(). Pass the raw request body, the Stripe-Signature header and that endpoint's secret.


Why do I get a 429 from OpenAI when I have credit?

A 429 can mean a rate limit or an exhausted credit balance or spend limit. Read error.code. Rate limits are worth retrying after the Retry-After interval, while codes such as credit_balance_exhausted need a billing change.


I raised my Vercel spend amount and production is still paused. Why?

Projects do not unpause automatically when you raise the amount. Resume each project in the dashboard or through the REST API. Pausing only affects production deployments, and AI Gateway key usage keeps counting toward the spend amount while projects are paused.


Does Stripe deliver events in the order they happened?

No. Delivery order is not guaranteed, and events can arrive more than once. Track event IDs, and fetch the current invoice or subscription from the API instead of trusting the order of arrival.


Sources

← Back to the full report