8.9 KiB
8.9 KiB
Polar Webhooks
Event handling, signature verification, and monitoring.
Setup
- Org Settings → Webhooks
- Enter endpoint URL (publicly accessible)
- Receive webhook secret (base64 encoded)
- Select event types
- Save configuration
Requirements:
- HTTPS endpoint
- Respond within 20 seconds
- Return 2xx status code
Signature Verification
Headers
webhook-id: msg_xxx
webhook-signature: v1,signature_xxx
webhook-timestamp: 1642000000
TypeScript Verification
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
app.post('/webhook/polar', (req, res) => {
try {
const event = validateEvent(
req.body,
req.headers,
process.env.POLAR_WEBHOOK_SECRET
);
// Event is valid, process it
await handleEvent(event);
res.json({ received: true });
} catch (error) {
if (error instanceof WebhookVerificationError) {
console.error('Invalid webhook signature');
return res.status(400).json({ error: 'Invalid signature' });
}
throw error;
}
});
Python Verification
from polar_sdk.webhooks import validate_event, WebhookVerificationError
@app.route('/webhook/polar', methods=['POST'])
def polar_webhook():
try:
event = validate_event(
request.get_data(),
dict(request.headers),
os.environ['POLAR_WEBHOOK_SECRET']
)
handle_event(event)
return {'received': True}
except WebhookVerificationError:
return {'error': 'Invalid signature'}, 400
Manual Verification
import crypto from 'crypto';
function verifySignature(payload, headers, secret) {
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'].split(',');
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signedPayload)
.digest('base64');
return signatures.some(sig => {
const [version, signature] = sig.split('=');
return version === 'v1' && signature === expectedSignature;
});
}
Event Types
Checkout
checkout.created- Checkout session createdcheckout.updated- Session updated
Order
order.created- Order created (checkbilling_reason)purchase- One-time productsubscription_create- New subscriptionsubscription_cycle- Renewalsubscription_update- Plan change
order.paid- Payment confirmedorder.updated- Order updatedorder.refunded- Refund processed
Subscription
subscription.created- Subscription createdsubscription.active- Subscription activatedsubscription.updated- Subscription modifiedsubscription.canceled- Cancellation scheduledsubscription.revoked- Subscription terminated
Note: Multiple events may fire for single action
Customer
customer.created- Customer createdcustomer.updated- Customer modifiedcustomer.deleted- Customer deletedcustomer.state_changed- Benefits/subscriptions changed
Benefit Grant
benefit_grant.created- Benefit grantedbenefit_grant.updated- Grant modifiedbenefit_grant.revoked- Benefit revoked
Refund
refund.created- Refund initiatedrefund.updated- Refund status changed
Product
product.created- Product createdproduct.updated- Product modified
Event Structure
{
"type": "order.paid",
"data": {
"id": "order_xxx",
"amount": 2000,
"currency": "USD",
"billing_reason": "purchase",
"customer": { ... },
"product": { ... },
"subscription": null,
"metadata": { ... }
}
}
Handler Implementation
Basic Handler
async function handleEvent(event) {
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data);
break;
case 'subscription.active':
await grantAccess(event.data.customer_id);
break;
case 'subscription.revoked':
await revokeAccess(event.data.customer_id);
break;
case 'benefit_grant.created':
await notifyBenefitGranted(event.data);
break;
default:
console.log(`Unhandled event: ${event.type}`);
}
}
Order Handler
async function handleOrderPaid(order) {
// Handle different billing reasons
switch (order.billing_reason) {
case 'purchase':
await fulfillOneTimeOrder(order);
break;
case 'subscription_create':
await handleNewSubscription(order);
break;
case 'subscription_cycle':
await handleRenewal(order);
break;
case 'subscription_update':
await handleUpgrade(order);
break;
}
}
Customer State Handler
async function handleCustomerStateChanged(customer) {
// Customer state includes:
// - active_subscriptions
// - active_benefits
const hasActiveSubscription = customer.active_subscriptions.length > 0;
if (hasActiveSubscription) {
await enableFeatures(customer.external_id);
} else {
await disableFeatures(customer.external_id);
}
}
Best Practices
1. Respond Immediately
app.post('/webhook/polar', async (req, res) => {
// Respond quickly
res.json({ received: true });
// Queue for background processing
await webhookQueue.add('polar-webhook', req.body);
});
2. Idempotency
async function handleEvent(event) {
// Check if already processed
const exists = await db.processedEvents.findOne({
webhook_id: event.id
});
if (exists) {
console.log('Event already processed');
return;
}
// Process event
await processEvent(event);
// Mark as processed
await db.processedEvents.insert({
webhook_id: event.id,
processed_at: new Date()
});
}
3. Retry Logic
async function processWithRetry(event, maxRetries = 3) {
let attempt = 0;
while (attempt < maxRetries) {
try {
await handleEvent(event);
return;
} catch (error) {
attempt++;
if (attempt >= maxRetries) throw error;
await sleep(1000 * attempt);
}
}
}
4. Error Handling
app.post('/webhook/polar', async (req, res) => {
try {
const event = validateEvent(req.body, req.headers, secret);
res.json({ received: true });
await processWithRetry(event);
} catch (error) {
console.error('Webhook processing failed:', error);
// Log to error tracking service
await logError(error, req.body);
if (error instanceof WebhookVerificationError) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Return 2xx even on processing errors
// Polar will retry if non-2xx
res.json({ received: true });
}
});
5. Logging
logger.info('Webhook received', {
event_type: event.type,
event_id: event.id,
customer_id: event.data.customer?.id,
amount: event.data.amount
});
Monitoring
Dashboard Features
- View webhook attempts
- Check response status
- Review retry history
- Manual retry option
- Filter by event type
- Search by customer
Application Monitoring
const metrics = {
webhooks_received: counter('polar_webhooks_received_total'),
webhooks_processed: counter('polar_webhooks_processed_total'),
webhooks_failed: counter('polar_webhooks_failed_total'),
processing_time: histogram('polar_webhook_processing_seconds')
};
app.post('/webhook/polar', async (req, res) => {
metrics.webhooks_received.inc({ type: req.body.type });
const timer = metrics.processing_time.startTimer();
try {
await handleEvent(req.body);
metrics.webhooks_processed.inc({ type: req.body.type });
} catch (error) {
metrics.webhooks_failed.inc({ type: req.body.type });
} finally {
timer();
}
res.json({ received: true });
});
Framework Adapters
Next.js
import { validateEvent } from '@polar-sh/nextjs/webhooks';
export async function POST(req: Request) {
const event = await validateEvent(req);
await handleEvent(event);
return Response.json({ received: true });
}
Laravel
use Polar\Webhooks\WebhookHandler;
Route::post('/webhook/polar', function (Request $request) {
$event = WebhookHandler::validate(
$request->getContent(),
$request->headers->all(),
config('polar.webhook_secret')
);
dispatch(new ProcessPolarWebhook($event));
return response()->json(['received' => true]);
});
Testing
Manual Testing
# Use Polar dashboard to send test webhooks
# Or use webhook testing tools
curl -X POST https://your-domain.com/webhook/polar \
-H "Content-Type: application/json" \
-H "webhook-id: msg_test" \
-H "webhook-timestamp: $(date +%s)" \
-H "webhook-signature: v1,test_signature" \
-d '{"type":"order.paid","data":{...}}'
Local Testing with ngrok
# Expose local server
ngrok http 3000
# Use ngrok URL in Polar webhook settings
https://abc123.ngrok.io/webhook/polar