Tutorial

Automating License Provisioning with Paddle Webhooks

TOT
Traffic Orchestrator Team
Engineering
March 25, 2026 11 min read 1,135 words
Share

Why Paddle for Software Licensing

Paddle Billing is a merchant-of-record platform designed specifically for software companies. Like LemonSqueezy, it handles sales tax, VAT, and compliance globally — but Paddle also provides built-in subscription management, invoicing, and a checkout overlay that converts well on desktop and mobile.

Paddle excels at subscription billing but doesn't natively manage domain-bound license keys, activation limits, or feature entitlements. By connecting Paddle's webhooks to a license management API, you get the best of both worlds: Paddle handles payments and compliance while your license system handles access control.

Paddle Billing Webhooks

Paddle Billing (v2) uses a modern event notification system. When a subscription is created, updated, or cancelled, Paddle sends a signed webhook to your configured endpoint. Each payload includes an event_type, a data object with the resource details, and a signature header for verification.

Paddle Classic vs. Paddle Billing: This guide covers Paddle Billing (v2), the current platform. If you're on Paddle Classic, the event names and payload shapes differ — check Paddle's migration guide for the mapping.

Key Paddle Events for License Management

Paddle Billing uses descriptive event names that map cleanly to license lifecycle actions:

Paddle Event License Action Priority
transaction.completed Create new license key Critical
subscription.updated Upgrade/downgrade license tier High
subscription.canceled Schedule license revocation Critical
subscription.past_due Flag license for grace period Medium
adjustment.created Handle refund — revoke license Critical
subscription.activated Activate paused license High

Verifying Paddle Webhook Signatures

Paddle Billing signs webhooks using an H1 signature scheme. Each webhook request includes a Paddle-Signature header containing a timestamp and signature. You verify by computing an HMAC-SHA256 of the timestamp + payload against your webhook secret:

import { createHmac, timingSafeEqual } from 'crypto'

const verifyPaddleSignature = (
  payload: string,
  header: string,
  secret: string
): boolean => {
  // Parse header: ts=TIMESTAMP;h1=SIGNATURE
  const parts = Object.fromEntries(
    header.split(';').map(p => p.split('='))
  )

  const ts = parts['ts']
  const expectedSig = parts['h1']

  // Build signed payload: timestamp:body
  const signedPayload = `${ts}:${payload}`
  const hmac = createHmac('sha256', secret)
  const computed = hmac.update(signedPayload).digest('hex')

  return timingSafeEqual(
    Buffer.from(computed),
    Buffer.from(expectedSig)
  )
}

// In your webhook handler
const handlePaddleWebhook = async (c) => {
  const signature = c.req.header('paddle-signature')
  const body = await c.req.text()

  if (!verifyPaddleSignature(body, signature, PADDLE_SECRET)) {
    return c.json({ error: 'Invalid signature' }, 401)
  }

  const event = JSON.parse(body)
  await dispatchPaddleEvent(event)
  return c.json({ received: true })
}
Use timing-safe comparison. Regular string comparison (===) is vulnerable to timing attacks. Always use timingSafeEqual or equivalent constant-time comparison when verifying webhook signatures.

Creating Licenses on Purchase

When a customer completes checkout, Paddle fires transaction.completed. The payload includes the customer details, the price/product they purchased, and the subscription ID:

const handleTransactionCompleted = async (event) => {
  const { data } = event
  const customer = data.customer
  const items = data.items

  // Map Paddle price to license tier
  const priceId = items[0]?.price?.id
  const tier = mapPaddlePriceToTier(priceId)

  // Create the license via Traffic Orchestrator API
  const license = await fetch(
    'https://api.trafficorchestrator.com/api/v1/licenses',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        product_id: 'your-product-id',
        customer_email: customer.email,
        customer_name: customer.name,
        tier: tier.name,
        max_domains: tier.domains,
        metadata: {
          paddle_customer_id: customer.id,
          paddle_subscription_id: data.subscription_id,
          paddle_transaction_id: data.id
        }
      })
    }
  )

  console.log('License provisioned:', customer.email)
}

Handling Plan Changes

Paddle fires subscription.updated when a customer upgrades, downgrades, or changes billing frequency. The payload includes the new price ID for the items array:

const handleSubscriptionUpdated = async (event) => {
  const { data } = event
  const customerId = data.customer_id
  const newPriceId = data.items[0]?.price?.id
  const status = data.status

  if (status !== 'active') return

  const newTier = mapPaddlePriceToTier(newPriceId)
  const license = await findLicenseByPaddleCustomerId(
    customerId
  )

  if (!license) return

  await fetch(
    `https://api.trafficorchestrator.com/api/v1/licenses/${license.id}`,
    {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        tier: newTier.name,
        max_domains: newTier.domains,
        features: newTier.features
      })
    }
  )
}

Grace Periods and Dunning

Paddle has built-in dunning management — it automatically retries failed payments and sends reminder emails to customers. Your webhook handler should complement this with license-level grace period logic:

  1. subscription.past_due: Mark the license as "at risk" — send an in-app warning but keep it active
  2. After Paddle exhausts retries: subscription.canceled fires — schedule revocation for end of billing period
  3. Customer updates payment: subscription.activated fires — clear the "at risk" flag
const handlePastDue = async (event) => {
  const customerId = event.data.customer_id
  const license = await findLicenseByPaddleCustomerId(
    customerId
  )

  if (!license) return

  // Mark as at-risk but keep active
  await fetch(
    `https://api.trafficorchestrator.com/api/v1/licenses/${license.id}`,
    {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        metadata: {
          ...license.metadata,
          payment_status: 'past_due',
          grace_period_start: new Date().toISOString()
        }
      })
    }
  )
}

Price-to-Tier Mapping

Paddle uses Price IDs (prefixed with pri_) to identify different plans and billing intervals:

const PADDLE_PRICE_MAP = {
  'pri_starter_mo':  { name: 'starter',  domains: 3,  features: ['basic'] },
  'pri_starter_yr':  { name: 'starter',  domains: 3,  features: ['basic'] },
  'pri_pro_mo':      { name: 'pro',      domains: 10, features: ['basic', 'api', 'analytics'] },
  'pri_pro_yr':      { name: 'pro',      domains: 10, features: ['basic', 'api', 'analytics'] },
  'pri_business_mo': { name: 'business', domains: 25, features: ['basic', 'api', 'analytics', 'whitelabel'] },
  'pri_business_yr': { name: 'business', domains: 25, features: ['basic', 'api', 'analytics', 'whitelabel'] },
}

const mapPaddlePriceToTier = (priceId: string) => {
  return PADDLE_PRICE_MAP[priceId] ?? {
    name: 'starter', domains: 3, features: ['basic']
  }
}

Idempotency with Paddle

Paddle includes an event_id in every webhook payload. Use this to deduplicate events:

  • Check before processing — Query your database/KV for the event_id before handling
  • Use UPSERT for license updates — Duplicate subscription updates won't create orphaned records
  • Return 200 immediately — Paddle retries on non-2xx responses for up to 7 days

Testing Your Integration

Paddle provides a sandbox environment with full webhook support:

  1. Create a Sandbox account at sandbox-vendors.paddle.com
  2. Configure a webhook notification pointing to your staging endpoint
  3. Create a test checkout and complete it with Paddle's test card numbers
  4. Monitor webhook deliveries in the Paddle dashboard under Developer Tools → Events

Production Checklist

Before going live with Paddle + automated licensing:

  • ☑ Webhook signature verification uses timing-safe comparison
  • ☑ All critical events are handled (transaction.completed, subscription.updated, subscription.canceled, adjustment.created)
  • ☑ Idempotency prevents duplicate license creation
  • ☑ Grace period logic complements Paddle's built-in dunning
  • ☑ Webhook endpoint responds within 5 seconds
  • ☑ Sandbox environment tested end-to-end
  • ☑ Monitoring and alerting configured for webhook failures

Traffic Orchestrator + Paddle Billing

Traffic Orchestrator's webhook endpoint works seamlessly with Paddle Billing. Point your Paddle webhook URL to your Traffic Orchestrator instance, map your Paddle prices to license tiers in the dashboard, and every transaction automatically provisions a domain-bound license key.

  • Multi-provider support — Use Paddle alongside Stripe, LemonSqueezy, or AppSumo — all provisioning flows converge into one license system
  • Instant delivery — License keys are emailed within seconds of completed purchase
  • Smart dunning integration — Grace periods work with Paddle's retry schedule, not against it
  • Global compliance handled — Paddle handles tax/VAT, Traffic Orchestrator handles license enforcement

Get started free → and connect Paddle Billing in under 5 minutes.

TOT
Traffic Orchestrator Team
Engineering

The engineering team behind Traffic Orchestrator, building enterprise-grade software licensing infrastructure used by developers worldwide.

Was this article helpful?
Get licensing insights delivered

Engineering deep-dives, security advisories, and product updates. Unsubscribe anytime.

Share this article
Free Plan Available

Ship licensing in your next release

5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.

2-minute setup No credit card Cancel anytime