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:
- Stripe Checkout — Customer selects a plan and completes payment. Your checkout session includes Traffic Orchestrator metadata (product ID, license tier).
- Webhook Event — Stripe fires
checkout.session.completedto your registered endpoint. - Signature Verification — Your handler validates the webhook signature using the endpoint secret.
- License Creation — Your handler calls
POST /api/v1/licenseswith the customer's email, product, and tier from the Stripe metadata. - Customer Notification — Traffic Orchestrator emails the license key to the customer automatically, or your system sends a custom delivery email.
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
}
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 purchasecustomer.subscription.updated— plan changecustomer.subscription.deleted— cancellationinvoice.payment_succeeded— renewalinvoice.payment_failed— failed chargecustomer.subscription.paused— pausecharge.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 })
}
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:
- Attempt 1 (Day 0): Send a warning email. License stays fully active.
- Attempt 2 (Day 3): Send a reminder with a direct link to update payment info.
- Attempt 3 (Day 7): Downgrade to free-tier limits. Data preserved.
- 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.idexists 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
- Start your local server with the TO staging API key
- Run
stripe listen --forward-to localhost:3000/api/webhooks/stripe - Create a Checkout session using Stripe's test card
4242 4242 4242 4242 - Complete checkout — verify the webhook fires
- Check your Traffic Orchestrator dashboard for the newly created license
- 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, andmetadata.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.
Ship licensing in your next release
5 licenses, 500 validations/month, full API access. Set up in under 5 minutes — no credit card required.