# Runbook: Stripe Webhook Failure

**Category:** Billing · Payments  
**Frequency:** Rare — typically clusters around Stripe outages or misconfiguration  
**Severity:** P1 — subscription state diverges from Stripe; users may lose/gain access incorrectly

---

## Trigger

- Stripe dashboard shows webhook delivery failures (HTTP 4xx/5xx, timeouts)
- A user's subscription status in-app doesn't match what Stripe shows
- `failed_jobs` table contains `StripeWebhookController`-related jobs
- User reports "I'm being charged but my account still shows Starter" (or vice versa)

---

## Symptoms

| Signal | Where to look |
|--------|--------------|
| Stripe dashboard webhook log shows failed deliveries | Stripe dashboard → Developers → Webhooks |
| `failed_jobs` table has webhook-related entries | DB query below |
| `subscriptions.last_webhook_at` is stale (>24h behind `updated_at`) | DB query below |
| User reports access doesn't match their Stripe subscription | Support ticket |
| Laravel log: `STRIPE_WEBHOOK_SECRET not configured` | `storage/logs/laravel.log` |

**Quick DB checks:**
```sql
-- Stale subscriptions (last webhook more than 6 hours behind)
SELECT u.email, s.stripe_id, s.stripe_status, s.last_webhook_at, s.updated_at
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.last_webhook_at < NOW() - INTERVAL 6 HOUR
   OR s.last_webhook_at IS NULL
ORDER BY s.updated_at DESC
LIMIT 20;

-- Failed jobs that look billing-related
SELECT id, queue, payload, exception, failed_at
FROM failed_jobs
WHERE payload LIKE '%Cashier%' OR payload LIKE '%Stripe%'
ORDER BY failed_at DESC
LIMIT 20;
```

---

## Diagnosis

### Step 1 — Check Stripe webhook delivery log

1. Open [Stripe dashboard](https://dashboard.stripe.com) → **Developers → Webhooks**.
2. Select the RankWizAI endpoint (typically `https://app.rankwiz.ai/stripe/webhook`).
3. Look at the **Recent deliveries** tab.
   - **All green** → the failure is local (Laravel side). Jump to Step 3.
   - **Red/timeout entries** → Stripe can't reach the endpoint. Jump to Step 2.

### Step 2 — Endpoint unreachable from Stripe

Possible causes:
- Deployment downtime during a Stripe retry window
- Firewall or WAF blocking Stripe IPs
- Wrong endpoint URL registered in Stripe

Stripe's retry schedule: 5m, 30m, 1h, 6h, 12h, 24h (×5 more at increasing intervals). Total retry window ≈ 72 hours.

**Immediate action:** verify the app is up and accessible from Stripe's IP ranges by hitting the health endpoint:
```bash
curl -s https://app.rankwiz.ai/health | jq .
```

If the app is up, check that the route `POST /stripe/webhook` is not behind authentication middleware or WAF rules.

### Step 3 — Secret mismatch

The most common single-webhook failure. Check:
```bash
php artisan tinker --execute="print config('cashier.webhook.secret') ? 'SET' : 'NOT SET';"
```

If `NOT SET`, `STRIPE_WEBHOOK_SECRET` is missing from `.env`. Retrieve from Stripe dashboard → Webhooks → select endpoint → **Signing secret**.

### Step 4 — Specific event type failing

If only certain event types fail (e.g. `customer.subscription.updated`), look at the Laravel log for the specific exception:
```bash
grep -n "StripeWebhookController\|stripe.*exception\|CustomerSubscriptionUpdated" storage/logs/laravel.log | tail -30
```

---

## Resolution

### Option A — Replay events from Stripe dashboard (preferred)

For missed events where the webhook was never delivered successfully:

1. Stripe dashboard → Developers → Webhooks → select endpoint.
2. Click the failed delivery → **Resend** (single event).
3. For bulk replay of a time window: use the [Stripe CLI](https://docs.stripe.com/webhooks/best-practices#replay-events):
   ```bash
   stripe events resend <event_id>
   # or list + pipe for a time window:
   stripe events list --created[gte]=<unix_timestamp> --limit 100 | jq '.data[].id' | xargs -I {} stripe events resend {}
   ```

### Option B — Manual Cashier sync (for specific user, no Stripe CLI)

If you know which user's subscription is out of sync:
```bash
php artisan cashier:sync --user=USER_ID_HERE
```

Or via Tinker:
```bash
php artisan tinker --execute="
  \$user = \App\Models\User::find(USER_ID_HERE);
  \$user->load('subscriptions');
  \$user->subscription()->syncStripeStatus();
  print 'synced: ' . \$user->subscription()->stripe_status . PHP_EOL;
"
```

### Option C — Replay failed_jobs entries

If the webhook was delivered but the job failed during processing:
```bash
php artisan queue:retry all --queue=default
# or specific job IDs:
php artisan queue:retry JOB_ID_1 JOB_ID_2
```

After replay, monitor `failed_jobs` for re-entries — if the same jobs keep failing, there's a code-level bug, not an infra issue.

### Option D — Correct a specific user's plan manually (last resort)

Only if Stripe sync won't converge within an acceptable window and the user needs access now:
```bash
php artisan tinker --execute="
  // Set the local subscription stripe_status to match Stripe reality
  \App\Models\Subscription::where('user_id', USER_ID)->update(['stripe_status' => 'active']);
  // Then sync properly — this line gets the real state from Stripe
  \App\Models\User::find(USER_ID)->subscription()->syncStripeStatus();
"
```

---

## Escalation

| Condition | Action |
|-----------|--------|
| Stripe shows all webhooks failing for >30 minutes | Check [status.stripe.com](https://status.stripe.com); if Stripe is up, page on-call infra |
| `STRIPE_WEBHOOK_SECRET not configured` in production log | Immediate: redeploy with correct env var. This means ALL webhook signature verification is failing. |
| >50 users with subscription status mismatch | Run `php artisan cashier:sync` across all users; page on-call |
| Webhook replay causes duplicate subscription lifecycle events | Check `StripeWebhookController::isStaleEvent()` logic — the stale-event guard should deduplicate |

---

## Related

- `app/Http/Controllers/Billing/StripeWebhookController.php` — webhook handler; includes `handleWebhook()` secret check and stale-event deduplication
- `app/Services/BillingService.php` — subscription state transitions
- `app/Models/Subscription.php` — local subscription model with `last_webhook_at` for ordering
- Stripe docs: [Webhook retries and tolerance](https://docs.stripe.com/webhooks/best-practices#retry-logic)
