Base Path /api/admin
🔒 All routes require authentication + admin role.
Every request must carry a valid access token (Authorization: Bearer <token> or access_token cookie) for a user with role = "admin". Non-admin users receive 403 Forbidden.
GET

User Subscription Diagnostic

🔒 Admin â–ļ
GET /api/admin/subscriptions/:userId

Returns the full subscription picture for a user — their DB state, all Stripe webhook events received for their customerId, and a mismatch analysis that compares the current DB state against the payload of the latest customer.subscription.updated event. If any fields differ, the response flags them with exact DB vs Stripe values and a plain-English recommendation.

â„šī¸ Event preference: the diagnostic always prefers the latest customer.subscription.updated event for comparison. It only falls back to customer.subscription.created when no updated event exists at all. When only a created event is found, mismatch comparison is skipped entirely — subscription.created always carries status: "incomplete" at creation time and is not a reliable sync target. The isCreatedEventOnly flag in the diagnostic block signals this case.
Path Parameters
ParamTypeDescription
userIdstringInternal user UUID (User.id)
Response
200 — Full diagnostic
{
  "success": true,
  "data": {

    // ── User DB row ──────────────────────────────────────────────────────
    "user": {
      "id": "2ab1ccfe-eab7-4d91-9daa-08214d222451",
      "email": "john@example.com",
      "username": "johndoe",
      "role": "user",
      "status": "active",
      "isSubscribed": true,
      "subscriptionStatus": "active",
      "stripeCustomerId": "cus_UDy82czRCdOdwi",
      "lastPaymentDate": "2026-04-12T10:00:00.000Z",
      "haveTrial": false,
      "trialStart": null,
      "trialEnd": null,
      "createdAt": "2026-01-10T08:00:00.000Z"
    },

    // ── Subscription DB row ──────────────────────────────────────────────
    "subscription": {
      "id": "sub-uuid",
      "stripeSubscriptionId": "sub_1TFWCcAGH2hwYStSo4F6k5F6",
      "customerId": "cus_UDy82czRCdOdwi",
      "status": "active",
      "priceId": "price_1TCPOWAGH2hwYStSgInDRDVJ",
      "amount": 29900,
      "currency": "usd",
      "periodStart": "2026-04-24T10:00:00.000Z",
      "periodEnd": "2026-05-24T10:00:00.000Z",
      "cancelAtPeriodEnd": false,
      "canceledAt": null,
      "planId": "enterprise",
      "pendingPlanId": null,
      "rawStripeData": { /* parsed Stripe snapshot stored at last processing */ },
      "plan": { "id": "enterprise", "name": "Enterprise", "settings": {} },
      "invoices": [
        {
          "invoiceId": "in_1TQkzXAGH2hwYStS5O9twV85",
          "amountPaid": 29900,
          "currency": "usd",
          "status": "paid",
          "createdAt": "2026-04-24T10:01:00.000Z"
        }
      ]
    },

    // ── All webhook events for this customerId (newest first, max 100) ───
    "events": [
      {
        "id": "evt_1TQkzXAGH2hwYStS...",
        "type": "customer.subscription.updated",
        "customerId": "cus_UDy82czRCdOdwi",
        "isProcessed": true,
        "processingError": null,
        "error": null,
        "attempts": 1,
        "processedAt": "2026-04-24T10:00:05.000Z",
        "createdAt": "2026-04-24T10:00:04.000Z"
      }
      // ... more events
    ],

    // ── Diagnostic block ─────────────────────────────────────────────────
    "diagnostic": {
      "latestSubscriptionEvent": {
        "id": "evt_1TQkzXAGH2hwYStS...",
        "type": "customer.subscription.updated",
        "stripeEventId": "evt_1TQkzXAGH2hwYStS...",
        "createdAt": "2026-04-24T10:00:04.000Z",
        "stripeCreatedAt": "2026-04-24T10:00:02.000Z",
        "isProcessed": true,
        "processingError": null,
        "stripeSub": { /* event.data.object — the Stripe Subscription */ },
        "previousAttributes": { /* event.data.previous_attributes */ }
      },
      "isCreatedEventOnly": false,
      "hasMismatch": false,
      "mismatchCount": 0,
      "mismatches": [],
      "recommendation": "DB state is in sync with the latest Stripe event."
    }
  }
}
Example — only subscription.created event exists (no false positive)
200 — isCreatedEventOnly (mismatch skipped)
{
  "diagnostic": {
    "latestSubscriptionEvent": {
      "type": "customer.subscription.created",
      "isProcessed": true,
      "stripeSub": { "status": "incomplete", /* ... */ }
    },
    "isCreatedEventOnly": true,
    "hasMismatch": false,
    "mismatchCount": 0,
    "mismatches": [],
    "recommendation": "Only a subscription.created event exists — this always has status 'incomplete' at creation time and is not a reliable sync target. The DB state was likely set correctly by checkout.session.completed. Check Stripe Dashboard → Developers → Webhooks for a missing customer.subscription.updated delivery for this customer."
  }
}
âš ī¸ When isCreatedEventOnly: true, the user's DB state (e.g. active) is not a problem — it was correctly set by checkout.session.completed. The gap is that Stripe never delivered a customer.subscription.updated event. Go to Stripe Dashboard → Developers → Webhooks and check for a failed or missing delivery for this customer, then resend it from Stripe's UI.
Example — when mismatch is detected
200 — Mismatch found (DB out of sync)
{
  "diagnostic": {
    "hasMismatch": true,
    "mismatchCount": 3,
    "mismatches": [
      {
        "field": "subscription.periodEnd",
        "dbValue":     "2026-04-24T10:00:00.000Z",
        "stripeValue": "2026-05-24T10:00:00.000Z",
        "description": "Billing period end in DB does not match Stripe event."
      },
      {
        "field": "user.isSubscribed",
        "dbValue":     false,
        "stripeValue": true,
        "description": "User isSubscribed flag does not match the active state derived from Stripe status."
      },
      {
        "field": "user.subscriptionStatus",
        "dbValue":     "past_due",
        "stripeValue": "active",
        "description": "User subscriptionStatus in DB does not match Stripe event status."
      }
    ],
    "recommendation": "DB state differs from latest Stripe event on 3 field(s). Use retry to resync."
  }
}
âš ī¸ When hasMismatch: true, find the latestSubscriptionEvent.id and call POST /api/admin/events/:eventId/retry to replay the handler and bring the DB back into sync.

Webhook Events

GET

List Webhook Events

🔒 Admin â–ļ
GET /api/admin/events

Paginated list of all WebhookEvent records (Stripe events received and stored). Payload is excluded from the list — use GET /events/:eventId to inspect a specific event's full Stripe payload.

Query Parameters
ParamTypeDefaultDescription
customerIdstring—Filter by Stripe customer ID
typestring—Filter by event type e.g. customer.subscription.updated
isProcessedboolean—true / false — filter by processing state
pagenumber1Page number (1-based)
limitnumber50Results per page (max 100)
Common queries
Examples
# All failed events
GET /api/admin/events?isProcessed=false

# All events for a specific customer
GET /api/admin/events?customerId=cus_UDy82czRCdOdwi

# Failed subscription updates only
GET /api/admin/events?type=customer.subscription.updated&isProcessed=false

# Paginated
GET /api/admin/events?page=2&limit=25
Response
200 — Event list
{
  "success": true,
  "data": {
    "events": [
      {
        "id": "evt_1TQkzXAGH2hwYStS...",
        "type": "customer.subscription.updated",
        "customerId": "cus_UDy82czRCdOdwi",
        "isProcessed": false,
        "processingError": "Subscription not found for update",
        "error": "Subscription not found for update",
        "attempts": 2,
        "processedAt": null,
        "createdAt": "2026-03-10T14:22:00.000Z",
        "updatedAt": "2026-03-10T14:22:05.000Z"
      }
    ],
    "pagination": {
      "total": 142,
      "page": 1,
      "limit": 50,
      "pages": 3
    }
  }
}
GET

Get Event (with payload)

🔒 Admin â–ļ
GET /api/admin/events/:eventId

Returns a single webhook event including its full parsedPayload — the complete Stripe.Event object. Use this to inspect the raw event before deciding to retry.

Path Parameters
ParamTypeDescription
eventIdstringStripe event ID e.g. evt_1TQkz...
Response
200 — Event with parsed payload
{
  "success": true,
  "data": {
    "event": {
      "id": "evt_1TQkzXAGH2hwYStS...",
      "type": "customer.subscription.updated",
      "customerId": "cus_UDy82czRCdOdwi",
      "isProcessed": false,
      "processingError": "Subscription not found for update",
      "attempts": 2,
      "processedAt": null,
      "createdAt": "2026-03-10T14:22:00.000Z",
      "parsedPayload": {
        "id": "evt_1TQkzXAGH2hwYStS...",
        "type": "customer.subscription.updated",
        "created": 1745496120,
        "data": {
          "object": { /* full Stripe Subscription object */ },
          "previous_attributes": { /* fields before the update */ }
        }
      }
    }
  }
}
POST

Retry Event

🔒 Admin â–ļ
POST /api/admin/events/:eventId/retry

Re-runs the Stripe webhook handler for a stored event. The handler re-applies all DB writes (subscription row, user row) as if the event just arrived from Stripe. Use this to resync a user whose DB state drifted from Stripe.

â„šī¸ What happens internally:
1. Parses the stored payload JSON back into a Stripe.Event
2. Calls stripeWebhookHandler(event, env, { adminRetry: true })
3. The adminRetry flag bypasses the idempotency check, so already-processed events can be replayed without being silently skipped
4. The handler increments attempts, stamps retriedByAdmin: true and lastRetriedAt, then updates isProcessed / processingError / processedAt
5. Returns the refreshed event record
âš ī¸ The adminRetry: true flag is only ever set from the admin service — the live webhook route always calls the handler without it. This means normal Stripe retries and replays still go through the idempotency check and will not double-apply billing state.
Path Parameters
ParamTypeDescription
eventIdstringStripe event ID to retry
Responses
200 OK — processed 422 — handler errored again 404 — event not found
200 — Retry succeeded
{
  "success": true,
  "message": "Event reprocessed successfully.",
  "data": {
    "event": {
      "id": "evt_1TQkzXAGH2hwYStS...",
      "type": "customer.subscription.updated",
      "customerId": "cus_UDy82czRCdOdwi",
      "isProcessed": true,
      "processingError": null,
      "attempts": 3,
      "processedAt": "2026-05-13T09:14:22.000Z",
      "retriedByAdmin": true,
      "lastRetriedAt": "2026-05-13T09:14:22.000Z",
      "createdAt": "2026-03-10T14:22:00.000Z",
      "updatedAt": "2026-05-13T09:14:22.000Z"
    }
  }
}
422 — Handler errored again (check processingError)
{
  "success": true,
  "message": "Event reprocessed but encountered an error — check processingError field.",
  "data": {
    "event": {
      "isProcessed": false,
      "processingError": "Subscription not found for update",
      "attempts": 4
    }
  }
}
404 — Event not found
{
  "success": false,
  "errorCode": "NOT_FOUND_ERROR",
  "message": "Webhook event evt_xxx not found."
}

Mismatch Analysis

The diagnostic endpoint compares the current DB state against the data.object of the latest customer.subscription.updated event stored in WebhookEvent.payload. It only falls back to customer.subscription.created when no updated event is on record — and in that case comparison is skipped entirely to avoid false positives (subscription.created always fires with status: "incomplete" regardless of actual payment state). Each mismatch entry identifies the diverging field, both values, and a description.

Diagnostic flags
FlagTypeMeaning
isCreatedEventOnlyboolean true when the only subscription event on record is subscription.created. Mismatch comparison is skipped; the DB state should be trusted. Check Stripe for a missing subscription.updated delivery.
hasMismatchboolean true when at least one field differs between DB and the latest Stripe event.
mismatchCountnumber Number of diverging fields (0 when isCreatedEventOnly is true).
Fields checked
FieldDB sourceStripe sourceTolerance
subscription.status Subscription.status data.object.status Exact
subscription.periodEnd Subscription.periodEnd data.object.items.data[0].current_period_end × 1000 60-second window
subscription.periodStart Subscription.periodStart data.object.items.data[0].current_period_start × 1000 60-second window
subscription.priceId Subscription.priceId data.object.items.data[0].price.id Exact
subscription.cancelAtPeriodEnd Subscription.cancelAtPeriodEnd data.object.cancel_at_period_end Exact
subscription.planId Subscription.planId data.object.metadata.planId Exact (best-effort)
user.isSubscribed User.isSubscribed derived: status === "active" âˆĨ "trialing" Exact
user.subscriptionStatus User.subscriptionStatus data.object.status Exact
â„šī¸ Why 60-second tolerance on date fields?
Unix timestamps (Stripe) are in seconds; JavaScript Date is in milliseconds. Small rounding differences when converting can appear as 1-second gaps. The 60-second window ignores these while still catching real billing-cycle mismatches (which differ by days or months).
âš ī¸ Why mismatches happen.
Stripe API 2025-12-15.clover moved current_period_start and current_period_end from the Subscription root object to each SubscriptionItem. Webhooks processed by older handler code would read undefined for these fields and leave periodStart / periodEnd unchanged in the DB — causing dates to stay on the previous billing cycle. Retrying those events with the updated handler fixes the drift.

Event Payload Shape

Every Stripe event received by the webhook endpoint is stored as JSON.stringify(event) in WebhookEvent.payload. When parsed, the top-level structure is:

Parsed WebhookEvent.payload — customer.subscription.updated
{
  "id":      "evt_1TQkzXAGH2hwYStS...",   // Stripe event ID
  "type":    "customer.subscription.updated",
  "created": 1745496120,                  // Unix timestamp (seconds)
  "data": {
    "object": {                             // ← the Stripe Subscription
      "id":     "sub_1TFWCcAGH2hwYStSo4F6k5F6",
      "status": "active",
      "cancel_at_period_end": false,
      "customer": "cus_UDy82czRCdOdwi",
      "metadata": { "planId": "enterprise", "userId": "2ab1ccfe-..." },
      "items": {
        "data": [{
          "id": "si_UDy8pvNyvQ7K8b",
          "current_period_start": 1777280234,  // ← on item, not root (API 2025-12-15)
          "current_period_end":   1779872234,
          "price": {
            "id":          "price_1TCPOWAGH2hwYStSgInDRDVJ",
            "unit_amount": 29900,
            "currency":    "usd",
            "recurring": { "interval": "month" }
          }
        }]
      }
    },
    "previous_attributes": {              // ← fields before the update
      "items": {
        "data": [{
          "current_period_end":   1777280234,
          "current_period_start": 1774601834
        }]
      },
      "latest_invoice": "in_1TFtHRAGH2hwYStSNtjtJRHn"
    }
  }
}
â„šī¸ data.previous_attributes shows what changed. In renewal events, only items.data[0].current_period_start, current_period_end, and latest_invoice change — the rest of the subscription stays the same. Comparing previous_attributes against the current data.object tells you exactly what the event was meant to update.

Admin Dashboard Workflow

Typical flow for diagnosing and fixing a user whose subscription is stuck:

Step-by-step
// 1. Get the user's full diagnostic
GET /api/admin/subscriptions/{userId}

// 2a. If diagnostic.isCreatedEventOnly === true:
//     The DB state is correct — set by checkout.session.completed.
//     No retry needed. Go to Stripe Dashboard → Developers → Webhooks
//     and resend the missing customer.subscription.updated for this customer.

// 2b. If diagnostic.hasMismatch === true:
//     Read diagnostic.mismatches to see exactly which fields are out of sync.

// 3. Find the event to retry:
//    → diagnostic.latestSubscriptionEvent.id  (quickest path)
//    → or pick from data.events[] (any event type, e.g. checkout.session.completed)

// 4. Optionally inspect the full payload before retrying
GET /api/admin/events/{eventId}

// 5. Retry the event (bypasses idempotency, stamps retriedByAdmin + lastRetriedAt)
POST /api/admin/events/{eventId}/retry

// 6. Re-run the diagnostic to confirm hasMismatch is now false
GET /api/admin/subscriptions/{userId}
// → "recommendation": "DB state is in sync with the latest Stripe event."