Skip to Content
👋 New here and not sure where to start? Follow the How to start guide →
WebhooksEvents and scenarios

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:

FieldTypeDescription
idstringSubscription identifier
statusstringOne of active, trialing, past_due, canceled, incomplete
current_period_startstring (ISO)Start of the current billing period
current_period_endstring (ISO)Next billing date. Not rewound on immediate cancellation — use ended_at for the actual end of access
trial_period_startstring (ISO)Start of trial, if any
trial_period_endstring (ISO)End of trial, if any
cancel_at_period_endbooleantrue when cancellation is scheduled for the end of the current period
cancel_atstring (ISO)When the subscription will be cancelled (set together with cancel_at_period_end: true)
canceled_atstring (ISO)When the user requested cancellation
ended_atstring (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:

DateWhat happensEvent
Jul 27User starts a trialsubscription.created — sub_A, trialing
Jul 30Trial ends, the card is declinedsubscription.updated — sub_A, past_due
Aug 2User pays again from scratchsubscription.created — sub_B, active
Aug 29The provider gives up on sub_A after ~30 days of dunningsubscription.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:

  1. Key subscription state by data.subscription.id, never by user or customer id.
  2. 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

StepEventDescription
1User makes paymentUser completes payment for lifetime access
2payment.completedPayment 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

StepEventDescription
1User subscribesUser completes payment for subscription
2subscription.createdSubscription 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:

Variant A: Cancellation at period end

StepEventDescription
1User cancelsUser requests cancellation
2subscription.updatedSubscription scheduled for cancellation, remains active
3End of periodPeriod reaches cancel_at
4subscription.cancelledSubscription 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

StepEventDescription
1User starts trialUser begins trial period
2subscription.createdSubscription created with status: trialing
3Trial period endsTrial period expires
4subscription.updatedSubscription 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)

StepEventDescription
1User changes planUser upgrades or downgrades subscription plan
2subscription.updatedSubscription 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

StepEventDescription
1Refund requestedActive subscription refund requested
2refund.createdRefund processed
3subscription.cancelledSubscription 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); };

Next Steps