← All guides

Test Polar.sh Webhooks Locally with Next.js 16 and Taupi

Build a signed Polar.sh webhook handler in Next.js 16 and test it locally over HTTPS with taupi secure tunnels.

polarwebhooksnextjslocalhosttestingtaupi

Introduction

When you integrate Polar.sh billing into a Next.js app, the real work happens in webhooks. Polar sends your server an HTTP request every time a customer subscribes, cancels, or gets a benefit granted. Your app receives that request and decides what to do: grant access, revoke a feature, record an order.

The problem is that Polar needs to reach your server over HTTPS at a public URL, but during development your Next.js app runs on localhost:3000. A tunnel fixes that.

In this guide you will:

  1. Create a Next.js 16 webhook route that handles Polar events
  2. Verify webhook signatures using Node.js built-in crypto (no extra dependencies)
  3. Expose your local server to Polar with a taupi tunnel
  4. Send test events from the Polar dashboard and watch them arrive

Everything here works with the free tier of both Polar and taupi. No deployment needed until you are ready.

Prerequisites

  • Node.js 18+ installed
  • A Polar.sh account with access to webhook settings
  • A terminal (macOS, Linux, or WSL)

Step 1 — Create a Next.js project

Skip this if you already have one.

npx create-next-app@latest polar-webhooks --ts --app --src-dir
cd polar-webhooks

Confirm it works:

npm run dev

Open http://localhost:3000, then stop the server with Ctrl+C.

Step 2 — Add the webhook secret to your environment

Create .env.local at the project root:

POLAR_WEBHOOK_SECRET=placeholder

You will replace placeholder with the real secret after configuring the webhook in Polar (Step 5). Next.js loads .env.local automatically and it is already gitignored by default.

Step 3 — Create the webhook route

Create the file src/app/api/webhooks/polar/route.ts:

import { createHmac, timingSafeEqual } from "crypto";

export const runtime = "nodejs";

type PolarEvent = {
  type: string;
  data: Record<string, unknown>;
};

// --- Signature verification (no dependencies needed) ---

function verifyWebhook(payload: string, headers: Headers): PolarEvent {
  const secret = process.env.POLAR_WEBHOOK_SECRET;
  if (!secret) throw new Error("POLAR_WEBHOOK_SECRET is not set");

  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signature = headers.get("webhook-signature");

  if (!id || !timestamp || !signature) {
    throw new Error("Missing webhook headers");
  }

  // Reject timestamps older than 5 minutes to prevent replay attacks
  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
  if (age > 300) throw new Error("Webhook timestamp too old");

  // The secret starts with "whsec_" followed by base64-encoded key bytes
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");

  // The signed content is: webhookId.timestamp.rawBody
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${payload}`)
    .digest("base64");

  // The header can contain multiple signatures separated by spaces
  const valid = signature.split(" ").some((sig) => {
    const value = sig.startsWith("v1,") ? sig.slice(3) : null;
    if (!value) return false;
    return timingSafeEqual(Buffer.from(expected), Buffer.from(value));
  });

  if (!valid) throw new Error("Invalid webhook signature");

  return JSON.parse(payload);
}

// --- Event handling ---

function handleEvent(event: PolarEvent): string {
  switch (event.type) {
    case "order.created":
      console.log("[polar] order created:", event.data);
      // Record the order, start fulfillment, etc.
      return "order_recorded";

    case "subscription.created":
      console.log("[polar] subscription created:", event.data);
      // Create a local subscription record, grant access
      return "subscription_created";

    case "subscription.updated":
      console.log("[polar] subscription updated:", event.data);
      // Update plan, status, or renewal details
      return "subscription_updated";

    case "subscription.canceled":
    case "subscription.revoked":
      console.log("[polar] subscription ended:", event.type);
      // Revoke paid access
      return "subscription_ended";

    case "benefit_grant.created":
      console.log("[polar] benefit granted:", event.data);
      // Enable a feature or entitlement
      return "benefit_granted";

    case "benefit_grant.revoked":
      console.log("[polar] benefit revoked:", event.data);
      // Disable a feature or entitlement
      return "benefit_revoked";

    default:
      console.log("[polar] unhandled event:", event.type);
      return "ignored";
  }
}

// --- Route handler ---

export async function POST(req: Request) {
  try {
    const payload = await req.text();
    const event = verifyWebhook(payload, req.headers);
    const action = handleEvent(event);

    return Response.json({ received: true, action });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Webhook verification failed";
    console.error("[polar] webhook error:", message);
    return Response.json({ error: message }, { status: 400 });
  }
}

export function GET() {
  return Response.json({ ok: true, endpoint: "/api/webhooks/polar" });
}

A few things to notice:

  • No extra packages. Node.js crypto handles HMAC-SHA256 natively. Polar uses the Standard Webhooks format under the hood, which is just HMAC signing over id.timestamp.body.
  • await req.text() reads the raw body. Never call req.json() before verification — re-serializing JSON changes the bytes and breaks the signature.
  • timingSafeEqual prevents timing attacks on the signature comparison.
  • Unknown events return 200. If Polar adds a new event type, your endpoint won't break.

Step 4 — Start your app and tunnel

Open two terminals.

Terminal 1 — start Next.js:

npm run dev

Confirm the route exists:

curl http://localhost:3000/api/webhooks/polar
# → {"ok":true,"endpoint":"/api/webhooks/polar"}

Terminal 2 — start a taupi tunnel:

curl -fsSL https://taupi.dev/install.sh | bash
taupi tunnel 3000

Taupi prints a public HTTPS URL. Your webhook endpoint is:

https://<your-subdomain>.taupi.dev/api/webhooks/polar

Keep both terminals open.

The free tier gives you one tunnel with five-minute sessions — enough for a quick test. For longer sessions while iterating, authenticate and pin a subdomain:

taupi auth <your-api-key>
taupi tunnel 3000 --subdomain myapp
# → https://myapp.taupi.dev/api/webhooks/polar

Step 5 — Configure the webhook in Polar

  1. Go to your Polar dashboard → Settings → Webhooks
  2. Create a new endpoint with your taupi URL: https://<your-subdomain>.taupi.dev/api/webhooks/polar
  3. Select the events you need (start with subscription.created, subscription.canceled, order.created, benefit_grant.created, benefit_grant.revoked)
  4. Save the endpoint
  5. Copy the webhook signing secret Polar shows you

Paste the secret into .env.local:

POLAR_WEBHOOK_SECRET=whsec_your_real_secret_here

Then restart Next.js (it only reads .env.local at startup):

# Stop with Ctrl+C, then:
npm run dev

Step 6 — Send a test event

In the Polar dashboard, click "Send test event" on your webhook endpoint. Watch your Next.js terminal — you should see something like:

[polar] subscription created: { id: '...', customer_id: '...', ... }

And Polar should report a 200 response.

If Polar reports 400:

  • Double-check POLAR_WEBHOOK_SECRET in .env.local matches the secret shown in Polar
  • Make sure you restarted npm run dev after editing .env.local
  • Confirm the endpoint URL in Polar matches your current taupi URL (free tunnels rotate)

If Polar reports a connection error:

  • Check that both npm run dev and taupi tunnel 3000 are still running
  • Verify the tunnel URL works: curl https://<your-subdomain>.taupi.dev/api/webhooks/polar

Step 7 — Add idempotency for production

Webhooks are at-least-once delivery. If your endpoint is slow or returns an error, Polar will retry. Your handler needs to be safe to run twice for the same event.

For development, add an in-memory guard:

const processed = new Set<string>();

export async function POST(req: Request) {
  const webhookId = req.headers.get("webhook-id");

  try {
    const payload = await req.text();
    const event = verifyWebhook(payload, req.headers);

    if (webhookId && processed.has(webhookId)) {
      return Response.json({ received: true, duplicate: true });
    }

    const action = handleEvent(event);

    if (webhookId) processed.add(webhookId);

    return Response.json({ received: true, action });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Webhook verification failed";
    console.error("[polar] webhook error:", message);
    return Response.json({ error: message }, { status: 400 });
  }
}

For production, replace the Set with a database table:

CREATE TABLE webhook_events (
  id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Before processing, insert the webhook ID. If the insert fails (duplicate key), skip processing.

Connecting events to your application

The webhook route verifies and dispatches events. Your actual business logic — granting access, updating a database, sending emails — should live in separate functions.

A clean structure:

src/
  app/api/webhooks/polar/route.ts   ← verification + dispatch
  lib/polar/handle-event.ts          ← business logic

For example, if you use benefit grants to control feature access:

// src/lib/polar/handle-event.ts
export async function grantBenefit(data: Record<string, unknown>) {
  const customerId = data.customer_id as string;
  const benefitId = data.benefit_id as string;

  // Look up local user by Polar customer ID
  // Upsert an entitlement row: { userId, benefitId, active: true }
}

export async function revokeBenefit(data: Record<string, unknown>) {
  const customerId = data.customer_id as string;
  const benefitId = data.benefit_id as string;

  // Set active: false for this benefit
}

Use Polar's stable customer ID for lookups, not email addresses — emails can change.

How the signature verification works

Polar uses the Standard Webhooks format. Every request includes three headers:

Header Value
webhook-id Unique delivery ID
webhook-timestamp Unix timestamp (seconds)
webhook-signature v1,<base64-hmac>

The signing secret starts with whsec_ followed by a base64-encoded key. To verify:

  1. Strip the whsec_ prefix and base64-decode to get the raw key
  2. Concatenate webhook-id.webhook-timestamp.raw-body
  3. Compute HMAC-SHA256 with the key
  4. Compare the result against the signature in the header

That is exactly what the verifyWebhook function in Step 3 does. No library needed — just createHmac and timingSafeEqual from Node.js crypto.

Going to production

When you deploy, create a separate Polar webhook endpoint pointing to your production URL:

https://yourapp.com/api/webhooks/polar

Use a different webhook secret for production. Set it in your hosting provider's environment variables, not in .env.local.

Key differences from local development:

  • Replace the in-memory Set with a database-backed idempotency check
  • Add structured logging instead of console.log
  • Keep webhook processing fast — offload slow work (emails, external API calls) to a background queue
  • Monitor webhook delivery in Polar's dashboard to catch failures early

The route code itself stays the same. The only thing that changes between local and production is the URL and the secret.

Recap

  1. Created a Next.js route at /api/webhooks/polar
  2. Verified Polar's webhook signatures with built-in Node.js crypto
  3. Dispatched events by type to handler functions
  4. Exposed localhost:3000 to Polar using taupi tunnel 3000
  5. Sent a test event from the Polar dashboard and confirmed it arrived

The full feedback loop — edit handler, send test event, check logs — takes seconds. No deployment, no mocking, no guessing what Polar actually sends.