The bug shows up as a customer who should not have access anymore.
Their Paddle subscription was paused, resumed, then canceled. Your app saw one webhook in the middle and kept the account enabled. Billing says canceled. Your product still says active.
That is not a checkout bug. It is a lifecycle bug.
The access check is too simple
The tempting handler is direct:
type PaddleWebhook = {
event_type:
| "subscription.activated"
| "subscription.paused"
| "subscription.resumed"
| "subscription.canceled";
data: {
id: string;
status: "trialing" | "active" | "paused" | "canceled";
customer_id: string;
};
};
export async function handlePaddleWebhook(event: PaddleWebhook) {
await accountAccess.set(event.data.customer_id, {
subscriptionId: event.data.id,
canUseProduct: event.data.status === "active",
});
}
That works only if the event you are processing is the current subscription state.
In real systems, that is the risky assumption. Webhooks can retry. Workers can lag. Your app can process subscription.resumed after it already received a later cancellation somewhere else. A single event is a signal. It is not the full access-control decision.
Reconcile before changing access
Treat Paddle lifecycle webhooks as a reason to re-read the subscription before you grant or revoke access.
type PaddleSubscriptionSignal = {
event_type:
| "subscription.activated"
| "subscription.paused"
| "subscription.resumed"
| "subscription.canceled";
data: {
id: string;
};
};
export async function handlePaddleSubscriptionEvent(event: PaddleSubscriptionSignal) {
const subscription = await paddle.subscriptions.get(event.data.id);
await accountAccess.reconcile({
subscriptionId: subscription.id,
customerId: subscription.customerId,
status: subscription.status,
canUseProduct: subscription.status === "active",
reconciledAt: new Date(),
});
}
The exact SDK shape is not the point. The rule is: access follows the latest subscription resource, not whichever webhook your worker happened to process first.
That rule also makes retries boring. If subscription.paused is delivered twice, the second read returns the same subscription state. If subscription.resumed is followed by subscription.canceled, the later read wins.
What to store locally
At minimum, your local subscription record should keep:
- Paddle subscription ID
- Paddle customer ID
- current status
- last reconciled time
- last event type seen
- whether access is currently enabled
Do not make subscription.activated the only path that grants access and subscription.canceled the only path that removes it. Paused and resumed states are part of the contract too.
The test most examples skip
Most billing examples stop at "subscription is active."
The test that catches access drift walks the lifecycle:
- read the trialing subscription
- activate it and grant access
- pause it and remove access
- resume it and grant access again
- cancel it and remove access
- read it back before trusting the final local state
That is the branch that proves your app can survive more than the first paid moment.
Receipts
Here's a real Paddle subscription lifecycle workflow run against the FetchSandbox sandbox, captured 2026-06-12:
GET /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp → 200
status: trialing
POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/activate → 200
status: active
POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/pause → 200
status: paused
POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/resume → 200
status: active
POST /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp/cancel → 200
status: canceled
GET /subscriptions/sub_01hv8xqmay5w5rfsnzkxzgy0yp → 200
status: canceled
webhook verification → 4/4 verified:
subscription.activated, subscription.paused, subscription.resumed, subscription.canceled
Full timeline: fetchsandbox.com/runs/9a316831cb?flow=run_fdb1a2eb-91d7-44b1-9c24-330706fbf456
The subscription ID sub_01hv8xqmay5w5rfsnzkxzgy0yp is the value your webhook handler should reconcile against. The final GET is the part that keeps your product access from depending on a stale lifecycle event.
How to test this before touching real billing
For Paddle, the finish line is not "checkout worked." It is "the app knows what access should be after the subscription changes state."
We use FetchSandbox to run the subscription lifecycle before wiring production billing logic. The point is not to replace Paddle's real sandbox. The point is to test the access-control states before your first paused or canceled customer exposes the gap.