Clerk Webhook
LedgerCore uses Clerk for authentication and Clerk Billing for subscription
management. Clerk sends user lifecycle + billing events to this webhook so we can keep our
users, billing_subscriptions, and user_credits tables in sync in real time.
Endpoint
POST /api/webhook/clerk
Authentication
Clerk signs each payload using Svix-style signature headers. The gateway verifies them with the webhook secret:
Svix-Id: msg_2x...
Svix-Timestamp: 1761750400
Svix-Signature: v1,base64=...
Set CLERK_WEBHOOK_SECRET to the secret Clerk provides when you create the endpoint in your
Clerk dashboard. In development mode the verification step can be bypassed for testing, but the
production build always verifies.
Supported event types
The gateway reads the raw request body for signature verification. Do not run JSON body parsers
before this route if you proxy it — Express's express.raw() is used
internally.
| Event | Effect on LedgerCore |
|---|---|
user.created | Inserts a row in users with a fresh internal UUID. |
user.updated | Updates email / metadata on the existing row. |
user.deleted | Soft-deletes the user and revokes API keys. |
subscription.active | Starts or renews a billing_subscriptions row and triggers credit grant. |
subscription.canceled | Marks the subscription cancelled at period end. |
subscription.past_due | Flags the subscription and disables credit burning on next request. |
subscriptionItem.active | Used for plan changes — upgrades/downgrades grant top-up credits. |
paymentAttempt.updated | Logged for observability; only succeeded attempts grant credits. |
Payload shape
Clerk's event envelope:
{
"type": "subscription.active",
"data": {
"id": "sub_2x...",
"payer_id": "user_32T5...",
"user_id": "user_32T5...",
"status": "active",
"plan": {
"id": "cplan_2x...",
"name": "Professional"
},
"period_start": 1761750400,
"period_end": 1764342400
},
"object": "event",
"timestamp": 1761750401
}
The gateway dispatches based on type — see src/core/webhook/clerkWebhook.ts for the full
handler matrix.
Credit granting
When a subscription.active or subscriptionItem.active event arrives, LedgerCore:
- Looks up the Clerk plan id (
cplan_*) in oursubscription_planstable. - Reads the associated credit amount for that plan.
- Calls the stored function
grant_user_credits(internalUserId, applicationId, amount, 'purchase', ...). - Emits
credit_balance_updated+credits_grantedon the user's WebSocket room.
The whole flow is idempotent — receiving the same event twice won't double-credit the user, since the transaction metadata is keyed on the Clerk event id.
Responses
| Status | When |
|---|---|
200 | Event accepted and processed (or skipped). |
401 | Signature verification failed. |
400 | Unknown event type or malformed payload. |
500 | Handler raised — Clerk will retry. |
Testing
Clerk's dashboard ships a "Send test event" button that fires a realistic payload. For manual testing against a running local gateway you can use the Clerk CLI:
clerk webhook forward --url http://localhost:8090/api/webhook/clerk
Then trigger the event in Clerk's admin UI; the CLI will tunnel it to your local gateway with a valid signature.