đĄ Admin
Admin-only endpoints for diagnosing subscription state mismatches and replaying Stripe webhook events.
Built to recover user accounts that fell out of sync due to webhook failures or Stripe API version
changes (e.g. current_period_* moving from Subscription root to SubscriptionItem in
API 2025-12-15.clover).
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.
User Subscription Diagnostic
đ Admin âļ
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.
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.
| Param | Type | Description |
|---|---|---|
| userId | string | Internal user UUID (User.id) |
{
"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."
}
}
}
{
"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."
}
}
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.
{
"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."
}
}
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
List Webhook Events
đ Admin âļ
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.
| Param | Type | Default | Description |
|---|---|---|---|
| customerId | string | â | Filter by Stripe customer ID |
| type | string | â | Filter by event type e.g. customer.subscription.updated |
| isProcessed | boolean | â | true / false â filter by processing state |
| page | number | 1 | Page number (1-based) |
| limit | number | 50 | Results per page (max 100) |
# 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
{
"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 Event (with payload)
đ Admin âļ
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.
| Param | Type | Description |
|---|---|---|
| eventId | string | Stripe event ID e.g. evt_1TQkz... |
{
"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 */ }
}
}
}
}
}
Retry Event
đ Admin âļ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.
1. Parses the stored
payload JSON back into a Stripe.Event2. Calls
stripeWebhookHandler(event, env, { adminRetry: true })3. The
adminRetry flag bypasses the idempotency check, so
already-processed events can be replayed without being silently skipped4. The handler increments
attempts, stamps retriedByAdmin: true
and lastRetriedAt, then updates isProcessed / processingError / processedAt5. Returns the refreshed event record
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.
| Param | Type | Description |
|---|---|---|
| eventId | string | Stripe event ID to retry |
{
"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"
}
}
}
{
"success": true,
"message": "Event reprocessed but encountered an error â check processingError field.",
"data": {
"event": {
"isProcessed": false,
"processingError": "Subscription not found for update",
"attempts": 4
}
}
}
{
"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.
| Flag | Type | Meaning |
|---|---|---|
isCreatedEventOnly | boolean |
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.
|
hasMismatch | boolean | true when at least one field differs between DB and the latest Stripe event. |
mismatchCount | number | Number of diverging fields (0 when isCreatedEventOnly is true). |
| Field | DB source | Stripe source | Tolerance |
|---|---|---|---|
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 |
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).
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:
{
"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:
// 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."