Webhook Events and Scenarios
Learn about different webhook events and their usage scenarios to properly handle subscription and payment workflows.
Event Types
1. payment.completed
Description: Completion of a one-time payment (lifetime)
When sent: When a one-time product or service payment is successful
Use cases:
- Lifetime access purchases
- One-time product purchases
- Single service payments
2. subscription.created
Description: Creation of a new subscription
When sent: When a user creates a subscription for the first time
Use cases:
- New subscription activation
- Trial period start
- Initial subscription setup
3. subscription.updated
Description: Update of an existing subscription
When sent: When subscription status, plan, or other parameters change
Use cases:
- Plan upgrades/downgrades
- Subscription renewals
- Status changes
- Reactivation
Paddle Note: Paddle may sometimes send multiple consecutive subscription.updated webhooks for the same subscription, even if business data (status, product, price) hasn’t changed. This is normal platform behavior and doesn’t require special handling.
4. subscription.cancelled
Description: Subscription cancellation
When sent: When a subscription is cancelled by user or system
Use cases:
- User cancellation
- Failed payment cancellation
- Administrative cancellation
This event reports one subscription, not the user’s overall status. The same user may hold another, still-active subscription on the same paywall — revoke access based on data.user.has_active_subscription, not on the event type. See Multiple Subscriptions per User.
5. refund.created
Description: Creation of a refund
When sent: When a payment refund is processed
Use cases:
- Customer refund requests
- Administrative refunds
- Dispute resolutions
Subscription Object Fields
The data.subscription object in subscription.* events contains the following fields:
| Field | Type | Description |
|---|---|---|
id | string | Subscription identifier |
status | string | One of active, trialing, past_due, canceled, incomplete |
current_period_start | string (ISO) | Start of the current billing period |
current_period_end | string (ISO) | Next billing date. Not rewound on immediate cancellation — use ended_at for the actual end of access |
trial_period_start | string (ISO) | Start of trial, if any |
trial_period_end | string (ISO) | End of trial, if any |
cancel_at_period_end | boolean | true when cancellation is scheduled for the end of the current period |
cancel_at | string (ISO) | When the subscription will be cancelled (set together with cancel_at_period_end: true) |
canceled_at | string (ISO) | When the user requested cancellation |
ended_at | string (ISO) | When the subscription actually ended. Set on immediate cancellation and after cancel_at is reached |
To determine whether a subscription is currently active, check status and ended_at. If ended_at is set and not in the future, access should be revoked even if current_period_end is later. If cancel_at_period_end is true, the subscription is active but will not renew.
Multiple Subscriptions per User
A user is not limited to one subscription per paywall, and events arrive per subscription. The most common way this bites integrations:
| Date | What happens | Event |
|---|---|---|
| Jul 27 | User starts a trial | subscription.created — sub_A, trialing |
| Jul 30 | Trial ends, the card is declined | subscription.updated — sub_A, past_due |
| Aug 2 | User pays again from scratch | subscription.created — sub_B, active |
| Aug 29 | The provider gives up on sub_A after ~30 days of dunning | subscription.cancelled — sub_A, canceled |
The cancellation on Aug 29 is correct, but it is about sub_A, which has been dead for a month. An integration that keys status off user.id or customer.id will read it as “this user cancelled” and revoke access from someone who is paying.
Two rules keep this correct:
- Key subscription state by
data.subscription.id, never by user or customer id. - Drive access off
data.user.has_active_subscription, which we compute across all of the user’s purchases on that paywall at the moment the event is built.
const handleSubscriptionCancelled = async ({ user, subscription }) => {
// Always record the per-subscription fact
await markSubscriptionCanceled(subscription.id);
// Revoke access only if nothing else is keeping the user alive.
// `undefined` means we could not compute it — fall back to the API
// rather than assuming the user has nothing.
if (user.has_active_subscription === false) {
await revokeAccess(user.id);
}
};Event Scenarios
Scenario 1: Lifetime Access Purchase
| Step | Event | Description |
|---|---|---|
| 1 | User makes payment | User completes payment for lifetime access |
| 2 | payment.completed | Payment successfully processed |
Payment Successful
{
"type": "payment.completed",
"data": {
"payment": {
"id": "pi_lifetime123",
"status": "succeeded"
},
"price": {
"interval": "lifetime"
}
}
}Event: payment.completed - payment successfully completed
In case of refund:
Refund Processed
{
"type": "refund.created",
"data": {
"refund": {
"id": "re_refund123",
"reason": "requested_by_customer",
"amount": 9999
}
}
}Event: refund.created - refund processed
Scenario 2: Subscription Without Trial
| Step | Event | Description |
|---|---|---|
| 1 | User subscribes | User completes payment for subscription |
| 2 | subscription.created | Subscription created and activated |
Subscription Created
{
"type": "subscription.created",
"data": {
"subscription": {
"id": "sub_active123",
"status": "active",
"current_period_start": "2024-01-15T12:30:45.000Z",
"current_period_end": "2024-02-15T12:30:45.000Z"
}
}
}Event: subscription.created - subscription created and activated
On subscription renewal:
Subscription Renewed
{
"type": "subscription.updated",
"data": {
"subscription": {
"id": "sub_active123",
"status": "active",
"current_period_start": "2024-02-15T12:30:45.000Z",
"current_period_end": "2024-03-15T12:30:45.000Z"
}
}
}Event: subscription.updated - subscription automatically renewed
Updated subscription.current_period_start and subscription.current_period_end will be sent for the current subscription
On subscription cancellation:
End of Period
Variant A: Cancellation at period end
| Step | Event | Description |
|---|---|---|
| 1 | User cancels | User requests cancellation |
| 2 | subscription.updated | Subscription scheduled for cancellation, remains active |
| 3 | End of period | Period reaches cancel_at |
| 4 | subscription.cancelled | Subscription actually cancelled |
First, when the user requests cancellation:
{
"type": "subscription.updated",
"data": {
"subscription": {
"id": "sub_active123",
"status": "active",
"cancel_at_period_end": true,
"cancel_at": "2025-02-15T12:30:45.000Z",
"canceled_at": "2024-02-15T12:30:45.000Z",
"current_period_end": "2025-02-15T12:30:45.000Z"
}
}
}Then, when the period ends:
{
"type": "subscription.cancelled",
"data": {
"subscription": {
"id": "sub_active123",
"status": "canceled",
"cancel_at_period_end": true,
"canceled_at": "2024-02-15T12:30:45.000Z",
"ended_at": "2025-02-15T12:30:45.000Z"
}
}
}Subscription remains in active status with cancel_at_period_end: true until cancel_at is reached, then status changes to canceled and ended_at is set.
Scenario 3: Subscription With Trial Period
| Step | Event | Description |
|---|---|---|
| 1 | User starts trial | User begins trial period |
| 2 | subscription.created | Subscription created with status: trialing |
| 3 | Trial period ends | Trial period expires |
| 4 | subscription.updated | Subscription updated with status: active |
Trial Started
{
"type": "subscription.created",
"data": {
"subscription": {
"id": "sub_trial123",
"status": "trialing",
"trial_period_start": "2024-01-15T12:30:45.000Z",
"trial_period_end": "2024-01-22T12:30:45.000Z"
}
}
}Event: subscription.created - subscription created with status “trialing”
Fields subscription.trial_period_start and subscription.trial_period_end will also be provided
Trial Ended
{
"type": "subscription.updated",
"data": {
"subscription": {
"id": "sub_trial123",
"status": "active",
"current_period_start": "2024-01-22T12:30:45.000Z",
"current_period_end": "2024-02-22T12:30:45.000Z"
}
}
}Event: subscription.updated - trial period ended, subscription became active
Updated subscription.current_period_start and subscription.current_period_end will be sent for the current subscription
If the first charge after the trial fails, the subscription moves to past_due instead: you will receive subscription.updated with status: "past_due", possibly alongside a short-lived active update emitted while the provider attempts the charge. Always apply the event with the latest occurred_at timestamp rather than the last one delivered.
If user cancels during trial:
Trial Cancelled
{
"type": "subscription.cancelled",
"data": {
"subscription": {
"id": "sub_trial123",
"status": "canceled",
"canceled_at": "2024-01-18T12:30:45.000Z",
"ended_at": "2024-01-18T12:30:45.000Z"
}
}
}Event: subscription.cancelled - subscription cancelled during trial period
Scenario 4: Plan Change (Upgrade/Downgrade)
| Step | Event | Description |
|---|---|---|
| 1 | User changes plan | User upgrades or downgrades subscription plan |
| 2 | subscription.updated | Subscription updated with new plan |
Plan Changed
{
"type": "subscription.updated",
"data": {
"subscription": {
"id": "sub_active123",
"status": "active"
},
"price": {
"id": "price_new_plan",
"unit_amount": 4999,
"interval": "month"
}
}
}Event: subscription.updated - subscription plan changed
Possible changes leading to subscription.updated:
- Plan change (upgrade/downgrade)
- Subscription renewal
- Reactivation of cancelled subscription
Scenario 5: Subscription Refund
| Step | Event | Description |
|---|---|---|
| 1 | Refund requested | Active subscription refund requested |
| 2 | refund.created | Refund processed |
| 3 | subscription.cancelled | Subscription cancelled due to refund |
Refund Created
{
"type": "refund.created",
"data": {
"refund": {
"id": "re_sub_refund456",
"reason": "requested_by_customer",
"amount": 2999
}
}
}Event: refund.created - refund processed
Subscription Cancelled
{
"type": "subscription.cancelled",
"data": {
"subscription": {
"id": "sub_active123",
"status": "canceled",
"canceled_at": "2024-01-20T12:30:45.000Z",
"ended_at": "2024-01-20T12:30:45.000Z"
}
}
}Event: subscription.cancelled - subscription cancelled due to refund
Event Handling Examples
Complete Event Handler
const handleWebhookEvent = async (event) => {
const { type, data } = event;
switch (type) {
case 'payment.completed':
await handlePaymentCompleted(data);
break;
case 'subscription.created':
await handleSubscriptionCreated(data);
break;
case 'subscription.updated':
await handleSubscriptionUpdated(data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(data);
break;
case 'refund.created':
await handleRefundCreated(data);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
};
// Handler implementations
const handlePaymentCompleted = async (data) => {
const { payment, price, user, customer } = data;
if (price.interval === 'lifetime') {
// Grant lifetime access
await grantLifetimeAccess(user.id);
console.log(`Lifetime access granted to user ${user.id}`);
} else {
// Handle one-time payment
await processOneTimePayment(payment.id, user.id);
}
// Send confirmation email
await sendPaymentConfirmation(customer.email, price.unit_amount);
};
const handleSubscriptionCreated = async (data) => {
const { subscription, user, customer } = data;
if (subscription.status === 'trialing') {
// Start trial period
await startTrialPeriod(user.id, subscription.trial_period_end);
console.log(`Trial started for user ${user.id}`);
} else if (subscription.status === 'active') {
// Activate subscription immediately
await activateSubscription(user.id, subscription.id);
console.log(`Subscription activated for user ${user.id}`);
}
// Send welcome email
await sendWelcomeEmail(customer.email, subscription.status);
};Best Practices
1. Handle Event Order
Event Order: Events may not always arrive in chronological order. Always check timestamps and handle out-of-order events. Use occurred_at (the payment provider’s event time) when present, falling back to created_at — two events emitted seconds apart may be delivered in any order.
Track the watermark per subscription, not per user — a user may have several, and events about a dead one must never overwrite a live one.
const eventTime = (event) => new Date(event.occurred_at || event.created_at);
const handleEventWithOrder = async (event) => {
const key = event.data.subscription?.id || event.data.payment?.id;
const lastProcessedTime = await getLastProcessedTime(key);
if (eventTime(event) < lastProcessedTime) {
console.log('Out of order event, skipping stale status update...');
// Handle out-of-order event logic
return;
}
await processEvent(event);
await updateLastProcessedTime(key, eventTime(event));
};2. Idempotency
const processedEvents = new Set();
const handleIdempotentEvent = async (event) => {
if (processedEvents.has(event.id)) {
console.log(`Event ${event.id} already processed`);
return;
}
await processEvent(event);
processedEvents.add(event.id);
};