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:

  1. Dashboard → Developers → Webhooks
  2. Add endpoint: https://yourapp.com/api/webhooks/billing
  3. Select: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted

Polar:

  1. Organization settings → Webhooks
  2. Add endpoint: https://yourapp.com/api/webhooks/billing
  3. 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 EventSDK Event
Stripe customer.subscription.createdsubscription.created
Stripe customer.subscription.updatedsubscription.updated
Stripe customer.subscription.deletedsubscription.deleted
Polar subscription.createdsubscription.created
Polar subscription.canceledsubscription.deleted

Automatic cache invalidation

When parseWebhook is called, the SDK:

  1. Verifies the signature
  2. Parses the event
  3. Automatically invalidates the cache for the affected user
  4. 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_xxx

Webhook 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
  }),
});