Tutorial

Automating License Provisioning with Stripe Webhooks

TOT
Traffic Orchestrator Team
Engineering
March 25, 2026 18 min read 2,668 words
Share

The Problem: Manual License Delivery

Stripe processes the payment in milliseconds. The customer's card is charged, the receipt is sent, and then… nothing. They wait for a license key that arrives when someone on your team wakes up, checks a dashboard, and manually copies it into an email.

That gap — between payment and license delivery — is where you lose customers. Support tickets pile up. Refund requests spike. And the customers who do stick around start their relationship with your product frustrated instead of excited.

The fix is webhook-driven provisioning: Stripe fires an event the instant a payment succeeds, your server catches it, creates a license via the Traffic Orchestrator API, and the customer receives their key before the Stripe receipt lands in their inbox.

Architecture Overview

The integration follows a five-stage pipeline. Every stage is automated — no human in the loop:

  1. Stripe Checkout — Customer selects a plan and completes payment. Your checkout session includes Traffic Orchestrator metadata (product ID, license tier).
  2. Webhook Event — Stripe fires checkout.session.completed to your registered endpoint.
  3. Signature Verification — Your handler validates the webhook signature using the endpoint secret.
  4. License Creation — Your handler calls POST /api/v1/licenses with the customer's email, product, and tier from the Stripe metadata.
  5. Customer Notification — Traffic Orchestrator emails the license key to the customer automatically, or your system sends a custom delivery email.
Why webhooks over polling? Stripe Checkout redirects the customer to a success URL, but the payment may still be processing when the redirect happens (especially for bank debits or 3D Secure). Webhooks guarantee you only provision after actual payment confirmation — no premature key delivery.

Step 1: Create Your Product in Traffic Orchestrator

Before configuring Stripe, set up your product and license tiers in Traffic Orchestrator. Each tier defines the capabilities the license grants: domain limits, feature flags, validation quotas, and expiry rules.

import TrafficOrchestrator from '@traffic-orchestrator/client'

const to = new TrafficOrchestrator({
  apiKey: process.env.TO_API_KEY
})

// Create the product that licenses will belong to
const product = await to.products.create({
  name: 'My SaaS Application',
  slug: 'my-saas-app'
})

// Your Stripe price IDs will map to these tiers
// Store these IDs — you'll reference them in Stripe metadata
console.log('Product ID:', product.id)

Keep your product.id — you'll embed it in every Stripe Checkout session so the webhook handler knows which product to provision against.

Step 2: Configure Stripe Checkout with License Metadata

The key to automated provisioning is embedding licensing metadata directly in the Stripe Checkout session. When the payment completes, the webhook event carries this metadata — your handler reads it and knows exactly what license to create without any external lookups.

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

// Create a checkout session with TO metadata
const createCheckoutSession = async (
  customerEmail: string,
  priceId: string,
  licenseTier: string
) => {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: customerEmail,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: 'https://yourapp.com/welcome?session={CHECKOUT_SESSION_ID}',
    cancel_url: 'https://yourapp.com/pricing',
    metadata: {
      // These fields drive automatic provisioning
      to_product_id: 'your-product-id',
      to_license_tier: licenseTier,
      to_max_domains: getTierDomainLimit(licenseTier).toString()
    }
  })

  return session.url
}

// Map tiers to their domain limits
const getTierDomainLimit = (tier: string): number => {
  const limits: Record<string, number> = {
    starter: 3,
    professional: 10,
    business: 25
  }
  return limits[tier] ?? 3
}
Always use metadata, not client_reference_id. Stripe metadata supports multiple key-value pairs and persists across the entire payment lifecycle. client_reference_id is a single string — insufficient for the product ID, tier, and domain limit your webhook handler needs.

Step 3: Set Up the Webhook Endpoint

Register your webhook endpoint in the Stripe Dashboard under Developers → Webhooks. Subscribe to these events:

  • checkout.session.completed — new purchase
  • customer.subscription.updated — plan change
  • customer.subscription.deleted — cancellation
  • invoice.payment_succeeded — renewal
  • invoice.payment_failed — failed charge
  • customer.subscription.paused — pause
  • charge.dispute.created — chargeback

Your webhook handler validates the signature, parses the event, and dispatches to the correct action:

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET

const handleStripeWebhook = async (req, res) => {
  const sig = req.headers['stripe-signature']
  const body = req.body // raw body — not parsed JSON

  // 1. Verify webhook signature — rejects spoofed events
  const event = stripe.webhooks.constructEvent(
    body,
    sig,
    WEBHOOK_SECRET
  )

  // 2. Idempotency: skip already-processed events
  const alreadyProcessed = await checkEventProcessed(event.id)
  if (alreadyProcessed) {
    return res.json({ received: true, skipped: true })
  }

  // 3. Dispatch to the correct handler
  switch (event.type) {
    case 'checkout.session.completed':
      await handleNewPurchase(event.data.object)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionChange(event.data.object)
      break
    case 'customer.subscription.deleted':
      await handleCancellation(event.data.object)
      break
    case 'invoice.payment_succeeded':
      await handleRenewal(event.data.object)
      break
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object)
      break
    case 'customer.subscription.paused':
      await handlePause(event.data.object)
      break
    case 'charge.dispute.created':
      await handleDispute(event.data.object)
      break
  }

  // 4. Mark event as processed
  await markEventProcessed(event.id)

  return res.json({ received: true })
}
Always verify signatures. Stripe signs every webhook with your endpoint secret. Skipping verification opens your system to spoofed events that could grant unauthorized licenses. stripe.webhooks.constructEvent() throws if the signature is invalid — let it throw.

Step 4: Auto-Provision the License

The checkout.session.completed handler extracts customer info and license configuration from the Stripe session metadata, then creates the license via the Traffic Orchestrator API:

import TrafficOrchestrator from '@traffic-orchestrator/client'

const to = new TrafficOrchestrator({
  apiKey: process.env.TO_API_KEY
})

const handleNewPurchase = async (session) => {
  // Retrieve the full session with line items
  const fullSession = await stripe.checkout.sessions.retrieve(
    session.id,
    { expand: ['line_items', 'customer'] }
  )

  const customer = fullSession.customer as Stripe.Customer
  const metadata = fullSession.metadata

  // Create the license using the Node.js SDK
  const license = await to.licenses.create({
    productId: metadata.to_product_id,
    customerEmail: customer.email,
    customerName: customer.name,
    tier: metadata.to_license_tier,
    maxDomains: parseInt(metadata.to_max_domains, 10),
    metadata: {
      stripe_customer_id: customer.id,
      stripe_subscription_id: fullSession.subscription
    }
  })

  // The license key is returned immediately
  // Traffic Orchestrator sends the delivery email automatically
  console.log('License provisioned:', license.id)
  console.log('License key:', license.key)
}

The POST /api/v1/licenses endpoint returns the generated license key synchronously. If you have email notifications enabled in your Traffic Orchestrator dashboard, the customer receives their key within seconds — before the Stripe receipt arrives.

Step 5: Customer Notification

Traffic Orchestrator sends a license delivery email automatically when a new license is created. The email includes the license key, bound domains, expiry date, and a link to your customer portal.

If you prefer custom delivery, disable automatic emails in the dashboard and handle delivery yourself:

// Option A: Traffic Orchestrator handles email delivery (default)
// Nothing to do — the email goes out when you call to.licenses.create()

// Option B: Custom delivery — send the key yourself
const license = await to.licenses.create({
  productId: metadata.to_product_id,
  customerEmail: customer.email,
  tier: metadata.to_license_tier,
  maxDomains: parseInt(metadata.to_max_domains, 10),
  skipNotification: true // suppress automatic email
})

// Send via your own email system
await sendCustomEmail({
  to: customer.email,
  subject: 'Your License Key',
  body: `Your license key: ${license.key}`
})

Handling Edge Cases

Payment Disputes (Chargebacks)

When a customer files a dispute, Stripe fires charge.dispute.created. You should immediately suspend the license — not revoke it. If the dispute is resolved in your favor, you can reactivate:

const handleDispute = async (dispute) => {
  const paymentIntent = dispute.payment_intent
  const license = await findLicenseByPaymentIntent(
    paymentIntent
  )

  if (!license) return

  // Suspend — not revoke — pending dispute resolution
  await to.licenses.update(license.id, {
    status: 'suspended',
    metadata: {
      dispute_id: dispute.id,
      dispute_reason: dispute.reason,
      suspended_at: new Date().toISOString()
    }
  })
}

Refunds

For refunds, revoke the license immediately. The customer has received their money back — continued access is unauthorized:

const handleRefund = async (charge) => {
  if (!charge.refunded) return

  const license = await findLicenseByStripeCustomer(
    charge.customer
  )

  if (!license) return

  await to.licenses.update(license.id, {
    status: 'revoked',
    metadata: {
      revocation_reason: 'refund',
      refunded_at: new Date().toISOString()
    }
  })
}

Subscription Lifecycle Mapping

Each Stripe subscription event maps to a specific license state change. This table is your reference implementation:

Stripe Event License Action Details
checkout.session.completed Create license New key generated, email sent, domains bound
customer.subscription.updated Upgrade/downgrade tier Adjust domain limits, feature flags, validation quota
invoice.payment_succeeded Extend expiry Push expires_at forward by billing interval
invoice.payment_failed Enter grace period License stays active; customer notified
customer.subscription.paused Suspend license Validation returns 403; data preserved
customer.subscription.deleted Revoke license Final cancellation — license marked inactive
charge.dispute.created Suspend license Pending dispute resolution
charge.refunded Revoke license Immediate revocation — customer refunded

Handling Upgrades and Downgrades

When a customer changes their plan, Stripe fires customer.subscription.updated. Your handler maps the new Stripe price ID to a license tier and adjusts the license configuration:

const PRICE_TIER_MAP: Record<string, { tier: string, domains: number, features: string[] }> = {
  'price_starter_monthly':  { tier: 'starter',      domains: 3,  features: ['basic'] },
  'price_starter_annual':   { tier: 'starter',      domains: 3,  features: ['basic'] },
  'price_pro_monthly':      { tier: 'professional', domains: 10, features: ['basic', 'api', 'floating'] },
  'price_pro_annual':       { tier: 'professional', domains: 10, features: ['basic', 'api', 'floating'] },
  'price_business_monthly': { tier: 'business',     domains: 25, features: ['basic', 'api', 'floating', 'sso'] },
  'price_business_annual':  { tier: 'business',     domains: 25, features: ['basic', 'api', 'floating', 'sso'] }
}

const handleSubscriptionChange = async (subscription) => {
  const priceId = subscription.items.data[0]?.price?.id
  const tierConfig = PRICE_TIER_MAP[priceId]

  if (!tierConfig) return

  const license = await findLicenseByStripeCustomer(
    subscription.customer
  )

  if (!license) return

  await to.licenses.update(license.id, {
    tier: tierConfig.tier,
    maxDomains: tierConfig.domains,
    features: tierConfig.features
  })
}

Renewal: Extending License Expiry

On each successful invoice payment, extend the license expiry by the billing interval:

const handleRenewal = async (invoice) => {
  // Only process subscription invoices (not one-time)
  if (!invoice.subscription) return

  const license = await findLicenseByStripeCustomer(
    invoice.customer
  )

  if (!license) return

  // Extend by the subscription interval
  const subscription = await stripe.subscriptions.retrieve(
    invoice.subscription
  )
  const intervalMonths = subscription.items.data[0]
    ?.price?.recurring?.interval === 'year' ? 12 : 1

  const currentExpiry = new Date(license.expiresAt)
  currentExpiry.setMonth(
    currentExpiry.getMonth() + intervalMonths
  )

  await to.licenses.update(license.id, {
    expiresAt: currentExpiry.toISOString(),
    status: 'active' // reactivate if previously in grace period
  })
}

Grace Periods for Failed Payments

Revoking licenses on the first failed charge is a bad experience. Cards expire, banks flag unusual transactions, billing addresses change. Implement a graduated grace period:

  1. Attempt 1 (Day 0): Send a warning email. License stays fully active.
  2. Attempt 2 (Day 3): Send a reminder with a direct link to update payment info.
  3. Attempt 3 (Day 7): Downgrade to free-tier limits. Data preserved.
  4. Final failure (Day 14): Suspend the license entirely.
const handlePaymentFailure = async (invoice) => {
  const attemptCount = invoice.attempt_count
  const license = await findLicenseByStripeCustomer(
    invoice.customer
  )

  if (!license) return

  if (attemptCount === 1) {
    // First failure — notify, license unchanged
    await to.licenses.update(license.id, {
      metadata: {
        payment_warning: 'first_failure',
        warning_sent_at: new Date().toISOString()
      }
    })
  } else if (attemptCount === 2) {
    // Second failure — send payment update link
    await to.licenses.update(license.id, {
      metadata: {
        payment_warning: 'second_failure',
        update_link_sent_at: new Date().toISOString()
      }
    })
  } else if (attemptCount >= 3) {
    // Third+ failure — downgrade to free tier
    await to.licenses.update(license.id, {
      tier: 'free',
      maxDomains: 1,
      metadata: {
        downgrade_reason: 'payment_failure',
        original_tier: license.tier,
        downgraded_at: new Date().toISOString()
      }
    })
  }
}

Cancellation and Pause

When a subscription is canceled, Stripe fires customer.subscription.deleted. Keep the license active until the end of the paid period (Stripe handles this via cancel_at_period_end), then revoke:

const handleCancellation = async (subscription) => {
  const license = await findLicenseByStripeCustomer(
    subscription.customer
  )

  if (!license) return

  // Revoke the license — the billing period has ended
  await to.licenses.update(license.id, {
    status: 'revoked',
    metadata: {
      cancellation_reason: subscription.cancellation_details
        ?.reason ?? 'customer_requested',
      cancelled_at: new Date().toISOString()
    }
  })
}

const handlePause = async (subscription) => {
  const license = await findLicenseByStripeCustomer(
    subscription.customer
  )

  if (!license) return

  // Suspend — preserve data, block validation
  await to.licenses.update(license.id, {
    status: 'suspended',
    metadata: {
      paused_at: new Date().toISOString(),
      resume_behavior: 'reactivate_on_resume'
    }
  })
}

Idempotency and Reliability

Stripe retries failed webhook deliveries for up to 3 days with exponential backoff. Your handler must be idempotent — processing the same event twice should produce the same result.

Three Strategies

  • Store processed event IDs — Before processing, check if event.id exists in your database or KV store. If so, return 200 immediately.
  • Use UPSERT operations — Design license creation as an upsert keyed on Stripe customer ID. Duplicate events update the existing record instead of creating a second license.
  • Respond fast, process async — Return 200 within 5 seconds to prevent Stripe retries. Queue heavy processing (API calls, emails) for background execution.
// Production-grade idempotency with a database
const checkEventProcessed = async (
  eventId: string
): Promise<boolean> => {
  const existing = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_id = ?',
    [eventId]
  )
  return existing.length > 0
}

const markEventProcessed = async (
  eventId: string
): Promise<void> => {
  await db.query(
    `INSERT INTO processed_webhooks (event_id, processed_at)
     VALUES (?, ?)
     ON CONFLICT (event_id) DO NOTHING`,
    [eventId, new Date().toISOString()]
  )
}

Testing with Stripe CLI

Stripe provides a CLI that forwards webhook events to your local development server. Combined with Traffic Orchestrator's staging environment, you can test the full flow without real charges:

Local Development Setup

# Install the Stripe CLI
brew install stripe/stripe-cli/stripe   # macOS
# or: scoop install stripe              # Windows

# Login to your Stripe account
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# In another terminal, trigger test events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted

End-to-End Test Flow

  1. Start your local server with the TO staging API key
  2. Run stripe listen --forward-to localhost:3000/api/webhooks/stripe
  3. Create a Checkout session using Stripe's test card 4242 4242 4242 4242
  4. Complete checkout — verify the webhook fires
  5. Check your Traffic Orchestrator dashboard for the newly created license
  6. Validate the license key works: POST /api/v1/licenses/validate

Automated Test Suite

describe('Stripe Webhook → License Provisioning', () => {
  it('creates a license on checkout.session.completed',
    async () => {
      const event = buildStripeEvent(
        'checkout.session.completed',
        {
          customer: 'cus_test_123',
          metadata: {
            to_product_id: 'prod_abc',
            to_license_tier: 'professional',
            to_max_domains: '10'
          }
        }
      )

      const response = await webhookHandler(event)

      expect(response.received).toBe(true)
      expect(mockToClient.licenses.create)
        .toHaveBeenCalledWith(
          expect.objectContaining({
            tier: 'professional',
            maxDomains: 10
          })
        )
    }
  )

  it('downgrades on third payment failure', async () => {
    const event = buildStripeEvent(
      'invoice.payment_failed',
      {
        customer: 'cus_test_123',
        attempt_count: 3
      }
    )

    await webhookHandler(event)

    expect(mockToClient.licenses.update)
      .toHaveBeenCalledWith(
        'lic_existing_id',
        expect.objectContaining({ tier: 'free' })
      )
  })

  it('skips duplicate events', async () => {
    const event = buildStripeEvent(
      'checkout.session.completed',
      { customer: 'cus_test_456' }
    )

    // Process once
    await webhookHandler(event)
    // Process again — should be skipped
    const result = await webhookHandler(event)

    expect(result.skipped).toBe(true)
    expect(mockToClient.licenses.create)
      .toHaveBeenCalledTimes(1)
  })
})

Monitoring and Alerting

Automated provisioning requires automated monitoring. Track these metrics and set alerts:

Metric Alert Threshold Action
Webhook delivery success rate < 99% Check endpoint availability and response times
Provisioning latency (webhook → license created) > 5 seconds Profile API calls; consider background queuing
License creation failures Any Alert immediately + add to manual review queue
Signature verification failures > 0 Potential spoofing attack — investigate source IPs
Duplicate event rate > 5% Review idempotency implementation
Orphaned payments (payment succeeded, no license) Any Critical — reconciliation job required

Production Checklist

Before deploying automated provisioning:

  • ☑ Webhook signature verification is implemented and tested
  • ☑ Stripe Checkout sessions include metadata.to_product_id, metadata.to_license_tier, and metadata.to_max_domains
  • ☑ All 7 Stripe events are handled (checkout completed, subscription updated/deleted/paused, invoice succeeded/failed, dispute created)
  • ☑ Idempotency prevents duplicate license creation on webhook retries
  • ☑ Grace period logic implemented for failed payments (warning → reminder → downgrade → suspend)
  • ☑ Refund handling revokes licenses immediately
  • ☑ Dispute handling suspends (not revokes) licenses pending resolution
  • ☑ Webhook endpoint responds within 5 seconds (queue heavy work for background)
  • ☑ Price-to-tier mapping covers all Stripe price IDs (monthly + annual)
  • ☑ End-to-end test verified with Stripe CLI + Traffic Orchestrator staging
  • ☑ Monitoring and alerting configured for all failure modes
  • ☑ License delivery email confirmed end-to-end

Traffic Orchestrator's Built-In Stripe Integration

Implementing all of this from scratch is significant engineering work. Traffic Orchestrator handles the entire provisioning pipeline natively:

  • Automatic provisioning — Connect your Stripe account once. Every checkout creates and delivers a license key with zero custom webhook code.
  • Tier mapping — Configure which Stripe products map to which license tiers in the dashboard.
  • Subscription lifecycle — Plan changes, renewals, pauses, and cancellations automatically adjust license state.
  • Grace periods — Configurable payment failure handling with automatic downgrades.
  • Dispute protection — Automatic suspension on chargeback, reactivation on resolution.
  • Idempotency and retries — Built-in deduplication and guaranteed delivery via durable execution.
  • Real-time analytics — Provisioning latency, conversion rates, and failure rates visible in your dashboard.

Get started free → and connect your Stripe account 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