Testing webhooks locally requires exposing your local server to the internet so Polar can send events to it. This guide covers multiple approaches for local webhook testing.
Using Polar CLI (Recommended)
The Polar CLI provides the easiest way to test webhooks locally by listening to events in real-time.
Installation
npm install -g @polar-sh/cli
# or
pnpm add -g @polar-sh/cli
Authentication
This opens your browser to authenticate and stores credentials locally.
Listen to Webhooks
polar webhooks listen --org your-org-slug --forward http://localhost:3000/webhooks
What this does:
Connects to Polar’s webhook event stream for your organization
Forwards all webhook events to your local endpoint
Shows real-time logs of events and responses
Applies the same signature headers as production webhooks
Filter by Event Type
polar webhooks listen \
--org your-org-slug \
--forward http://localhost:3000/webhooks \
--events order.paid,subscription.active
Test with a Sample Event
You can trigger a test event from the dashboard while the CLI is listening:
Go to Settings → Webhooks in your organization
Click “Send Test Event”
Select an event type
Watch it appear in your CLI and hit your local server
Using ngrok
Ngrok creates a public URL that tunnels to your localhost.
Installation
brew install ngrok
# or download from https://ngrok.com/download
Start Tunnel
This gives you a public URL like https://abc123.ngrok.io.
Create Webhook Endpoint
import polar_sdk
client = polar_sdk.Polar( access_token = "YOUR_ACCESS_TOKEN" )
endpoint = client.webhooks.create_endpoint(
url = "https://abc123.ngrok.io/webhooks" ,
format = "raw" ,
events = [ "order.paid" , "subscription.active" ],
organization_id = "YOUR_ORG_ID"
)
print ( f "Webhook secret: { endpoint.secret } " )
# Store this secret in your local .env file
Update Local Code
Add the webhook secret to your environment:
WEBHOOK_SECRET = "polar_whs_..."
Test the Webhook
Trigger an event in Polar (e.g., create a test order) and watch it hit your local server.
Remember to delete the ngrok webhook endpoint after testing. Each ngrok session creates a new URL, so you’ll need to update the endpoint URL each time.
Using LocalTunnel
LocalTunnel is a free alternative to ngrok.
Installation
npm install -g localtunnel
Start Tunnel
lt --port 3000 --subdomain my-polar-test
This gives you https://my-polar-test.loca.lt.
Create Webhook Endpoint
Follow the same steps as ngrok, using your LocalTunnel URL.
Sample Local Server
Here’s a complete example of a local webhook server for testing:
Python (FastAPI)
from fastapi import FastAPI, Request, HTTPException
from svix.webhooks import Webhook, WebhookVerificationError
import os
app = FastAPI()
WEBHOOK_SECRET = os.getenv( "WEBHOOK_SECRET" )
@app.post ( "/webhooks" )
async def webhook_handler ( request : Request):
# Get headers
webhook_id = request.headers.get( "webhook-id" )
webhook_timestamp = request.headers.get( "webhook-timestamp" )
webhook_signature = request.headers.get( "webhook-signature" )
if not all ([webhook_id, webhook_timestamp, webhook_signature]):
raise HTTPException( status_code = 400 , detail = "Missing webhook headers" )
# Get body
body = await request.body()
# Verify signature
wh = Webhook( WEBHOOK_SECRET )
try :
payload = wh.verify(body, {
"webhook-id" : webhook_id,
"webhook-timestamp" : webhook_timestamp,
"webhook-signature" : webhook_signature,
})
except WebhookVerificationError:
raise HTTPException( status_code = 401 , detail = "Invalid signature" )
# Log the event
print ( f "[ { payload[ 'type' ] } ] { payload[ 'data' ][ 'id' ] } " )
# Process based on type
if payload[ "type" ] == "order.paid" :
customer_email = payload[ "data" ][ "customer" ][ "email" ]
print ( f "Grant access to { customer_email } " )
elif payload[ "type" ] == "subscription.active" :
subscription_id = payload[ "data" ][ "id" ]
print ( f "Activate subscription { subscription_id } " )
return { "ok" : True }
if __name__ == "__main__" :
import uvicorn
uvicorn.run(app, host = "0.0.0.0" , port = 3000 )
Node.js (Express)
import express , { Request , Response } from "express" ;
import { Webhook } from "standardwebhooks" ;
const app = express ();
app . use ( express . json ());
const WEBHOOK_SECRET = process . env . WEBHOOK_SECRET ! ;
app . post ( "/webhooks" , async ( req : Request , res : Response ) => {
const webhookId = req . headers [ "webhook-id" ] as string ;
const webhookTimestamp = req . headers [ "webhook-timestamp" ] as string ;
const webhookSignature = req . headers [ "webhook-signature" ] as string ;
if ( ! webhookId || ! webhookTimestamp || ! webhookSignature ) {
return res . status ( 400 ). json ({ error: "Missing webhook headers" });
}
const body = JSON . stringify ( req . body );
const wh = new Webhook ( WEBHOOK_SECRET );
try {
const payload = wh . verify ( body , {
"webhook-id" : webhookId ,
"webhook-timestamp" : webhookTimestamp ,
"webhook-signature" : webhookSignature ,
}) as any ;
console . log ( `[ ${ payload . type } ] ${ payload . data . id } ` );
if ( payload . type === "order.paid" ) {
console . log ( `Grant access to ${ payload . data . customer . email } ` );
} else if ( payload . type === "subscription.active" ) {
console . log ( `Activate subscription ${ payload . data . id } ` );
}
res . json ({ ok: true });
} catch ( err ) {
console . error ( "Invalid signature" , err );
return res . status ( 401 ). json ({ error: "Invalid signature" });
}
});
app . listen ( 3000 , () => {
console . log ( "Webhook server listening on port 3000" );
});
Debugging Tips
Enable Verbose Logging
Log the full webhook payload for debugging:
import json
@app.post ( "/webhooks" )
async def webhook_handler ( request : Request):
body = await request.body()
payload = json.loads(body)
# Log full payload
print (json.dumps(payload, indent = 2 ))
# Verify and process...
@app.post ( "/webhooks" )
async def webhook_handler ( request : Request):
print ( "Headers:" , dict (request.headers))
print ( "Body:" , await request.body())
Validate Signature Separately
@app.post ( "/webhooks" )
async def webhook_handler ( request : Request):
# First, log everything
headers = {
"webhook-id" : request.headers.get( "webhook-id" ),
"webhook-timestamp" : request.headers.get( "webhook-timestamp" ),
"webhook-signature" : request.headers.get( "webhook-signature" ),
}
body = await request.body()
print ( f "Headers: { headers } " )
print ( f "Body: { body.decode() } " )
print ( f "Secret: { WEBHOOK_SECRET [: 20 ] } ..." )
# Then verify
wh = Webhook( WEBHOOK_SECRET )
try :
payload = wh.verify(body, headers)
print ( "✅ Signature valid" )
except Exception as e:
print ( f "❌ Signature invalid: { e } " )
raise HTTPException( status_code = 401 )
Check Delivery Logs
If events aren’t reaching your local server:
Go to Settings → Webhooks → Deliveries in the dashboard
Find recent deliveries to your endpoint
Check the HTTP status code and response
Look for error messages
Common Issues
Signature verification fails:
Make sure you’re using the exact secret from endpoint creation
Don’t modify the request body before verification
Check that you’re passing the raw body bytes, not parsed JSON
Webhooks not arriving:
Verify your tunnel is running (ngrok http 3000)
Check the endpoint URL in Polar dashboard
Ensure your local server is listening on the correct port
Check firewall settings
Timeout errors:
Return 2xx response within 10 seconds
Queue long-running tasks asynchronously
Testing Different Event Types
Create test events from the dashboard:
Orders: Create a test checkout and complete it
Subscriptions: Create a test subscription product and purchase it
Customers: Create a customer via API or dashboard
Refunds: Issue a refund for a test order
Alternatively, use the “Send Test Event” button in Settings → Webhooks to send synthetic events.
Next Steps
Webhook Events Learn about all available webhook events
Delivery & Security Implement signature verification for production