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.
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:
- Create a Next.js 16 webhook route that handles Polar events
- Verify webhook signatures using Node.js built-in
crypto(no extra dependencies) - Expose your local server to Polar with a taupi tunnel
- 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
cryptohandles HMAC-SHA256 natively. Polar uses the Standard Webhooks format under the hood, which is just HMAC signing overid.timestamp.body. await req.text()reads the raw body. Never callreq.json()before verification — re-serializing JSON changes the bytes and breaks the signature.timingSafeEqualprevents 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
- Go to your Polar dashboard → Settings → Webhooks
- Create a new endpoint with your taupi URL:
https://<your-subdomain>.taupi.dev/api/webhooks/polar - Select the events you need (start with
subscription.created,subscription.canceled,order.created,benefit_grant.created,benefit_grant.revoked) - Save the endpoint
- 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_SECRETin.env.localmatches the secret shown in Polar - Make sure you restarted
npm run devafter 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 devandtaupi tunnel 3000are 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:
- Strip the
whsec_prefix and base64-decode to get the raw key - Concatenate
webhook-id.webhook-timestamp.raw-body - Compute HMAC-SHA256 with the key
- 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
Setwith 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
- Created a Next.js route at
/api/webhooks/polar - Verified Polar's webhook signatures with built-in Node.js
crypto - Dispatched events by type to handler functions
- Exposed
localhost:3000to Polar usingtaupi tunnel 3000 - 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.