When it fires
After UpdateSubscription updates the row matching polarSubscriptionId and gets a row back. If no subscription matches that Polar id, the update returns nothing and no event is emitted.
Three inbound Polar handlers call it, all reached from POST /webhooks/polar:
| Polar event | Handler | changes on the emitted event |
|---|---|---|
subscription.updated | HandleSubscriptionUpdated | ["status", "currentPeriodStart", "currentPeriodEnd"] |
subscription.canceled | HandleSubscriptionCanceled | ["canceledAt"] |
subscription.revoked | HandleSubscriptionRevoked | ["status", "canceledAt", "deleted", "deletedAt"] |
There is no Envless subscription.canceled or subscription.revoked event type. WEBHOOK_EVENT_TYPES defines exactly two subscription types, subscription.created and subscription.updated, so subscription.updated is the only event that reports a cancellation or a revocation.
EmitWorkspaceEvent is called with actor: null, because these events are system-driven from the billing provider and not from a dashboard user. The delivered envelope still carries an actor object, with userId, name and email all set to null.
Payload
{
type: 'subscription.updated',
data: {
subscription: {
id: string
status: string // 'active' | 'trialing' | 'past_due' | 'canceled'
polarSubscriptionId: string
polarProductId: string
currentPeriodStart: string
currentPeriodEnd: string
canceledAt: string | null
}
changes: Array<'status' | 'currentPeriodStart' | 'currentPeriodEnd' | 'canceledAt' | 'deleted' | 'deletedAt'>
}
}currentPeriodStart and currentPeriodEnd are never null. Both columns are notNull on SubscriptionSchema, and HandleSubscriptionUpdated refuses the Polar webhook with a 400 when either is missing from the inbound body. canceledAt is the one nullable timestamp.
changes is the key set of the update, Object.keys(updateData) with updatedAt filtered out, so it is always emitted in this fixed order: status, currentPeriodStart, currentPeriodEnd, canceledAt, deleted, deletedAt. Only the keys actually written appear. Note that deleted and deletedAt can appear in changes even though the subscription object carries neither field.
The subscription object is the row read back after the write, so status is the stored value and not necessarily the thing that changed. Polar's subscription.canceled never touches status: a cancellation scheduled for the end of the period arrives with status still active and only canceledAt set.
status is whatever Polar sent. The four values above are the ones Envless recognises in SUBSCRIPTION_STATUS.
Example: cancellation scheduled at period end
{
"id": "0192a1b2-c3d4-7000-8000-0000000000e2",
"type": "subscription.updated",
"apiVersion": "2026-05-31",
"createdAt": "2026-06-10T15:00:00.000Z",
"workspaceId": "0192a1b2-c3d4-7000-8000-0000000000a0",
"actor": {
"userId": null,
"name": null,
"email": null
},
"data": {
"subscription": {
"id": "0192a1b2-c3d4-7000-8000-0000000000c0",
"status": "active",
"polarSubscriptionId": "sub_abc123",
"polarProductId": "prod_xyz789",
"currentPeriodStart": "2026-05-31T00:00:00.000Z",
"currentPeriodEnd": "2026-06-30T23:59:59.999Z",
"canceledAt": "2026-06-10T15:00:00.000Z"
},
"changes": ["canceledAt"]
}
}Example: revocation
HandleSubscriptionRevoked writes status, canceledAt, deleted and deletedAt in one update, then downgrades the workspace to the free plan.
{
"id": "0192a1b2-c3d4-7000-8000-0000000000e3",
"type": "subscription.updated",
"apiVersion": "2026-05-31",
"createdAt": "2026-06-30T23:59:59.999Z",
"workspaceId": "0192a1b2-c3d4-7000-8000-0000000000a0",
"actor": {
"userId": null,
"name": null,
"email": null
},
"data": {
"subscription": {
"id": "0192a1b2-c3d4-7000-8000-0000000000c0",
"status": "canceled",
"polarSubscriptionId": "sub_abc123",
"polarProductId": "prod_xyz789",
"currentPeriodStart": "2026-05-31T00:00:00.000Z",
"currentPeriodEnd": "2026-06-30T23:59:59.999Z",
"canceledAt": "2026-06-10T15:00:00.000Z"
},
"changes": ["status", "canceledAt", "deleted", "deletedAt"]
}
}