Webhooks
Automatic cache invalidation and normalized event handling.
Webhooks keep your cache in sync with your payment provider. When a subscription changes, the SDK automatically invalidates the cached entitlement.
Setting up webhooks
1. Create the endpoint
app/api/webhooks/billing/route.ts
import { billing } from "@/lib/billing";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
try {
const event = await billing.parseWebhook(body, signature);
console.log(`[billing] ${event.type} for user ${event.userId}`);
// Optional: Handle specific events
if (event.type === "subscription.deleted") {
await sendCancellationEmail(event.userId);
}
return new Response("ok");
} catch (err) {
console.error("[billing] Webhook error:", err);
return new Response("Invalid signature", { status: 400 });
}
}2. Configure your provider
Stripe:
- Dashboard → Developers → Webhooks
- Add endpoint:
https://yourapp.com/api/webhooks/billing - Select:
customer.subscription.created,customer.subscription.updated,customer.subscription.deleted
Polar:
- Organization settings → Webhooks
- Add endpoint:
https://yourapp.com/api/webhooks/billing - Select subscription events
Normalized events
All providers emit the same event types:
type BillingEvent = {
type: "subscription.created" | "subscription.updated" | "subscription.deleted";
userId: string;
productId: string;
timestamp: Date;
raw: unknown; // Original provider payload
};Event mapping
| Provider Event | SDK Event |
|---|---|
Stripe customer.subscription.created | subscription.created |
Stripe customer.subscription.updated | subscription.updated |
Stripe customer.subscription.deleted | subscription.deleted |
Polar subscription.created | subscription.created |
Polar subscription.canceled | subscription.deleted |
Automatic cache invalidation
When parseWebhook is called, the SDK:
- Verifies the signature
- Parses the event
- Automatically invalidates the cache for the affected user
- Returns the normalized event
Handling webhook retries
Payment providers retry failed webhooks. Make your handler idempotent:
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
try {
const event = await billing.parseWebhook(body, signature);
// Idempotent: Cache invalidation is safe to repeat
// Your custom logic should also be idempotent
return new Response("ok");
} catch (err) {
// Return 200 for events we don't handle
// Return 4xx only for signature failures
if (err.message.includes("signature")) {
return new Response("Invalid signature", { status: 400 });
}
return new Response("ok");
}
}Local development
Use provider CLIs to forward webhooks locally:
# Stripe
stripe listen --forward-to localhost:3000/api/webhooks/billing
# The CLI outputs a webhook secret - use it for local dev
STRIPE_WEBHOOK_SECRET=whsec_xxxWebhook security
Always verify webhook signatures. Never trust the payload without verification.
The SDK handles signature verification automatically when you provide the webhook secret:
const billing = createEnhancedClient({
provider: stripe({
apiKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, // Required
}),
});