406 lines
8.9 KiB
Markdown
406 lines
8.9 KiB
Markdown
# Polar Webhooks
|
|
|
|
Event handling, signature verification, and monitoring.
|
|
|
|
## Setup
|
|
|
|
1. Org Settings → Webhooks
|
|
2. Enter endpoint URL (publicly accessible)
|
|
3. Receive webhook secret (base64 encoded)
|
|
4. Select event types
|
|
5. 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
|
|
```typescript
|
|
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
|
|
```python
|
|
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
|
|
```typescript
|
|
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 created
|
|
- `checkout.updated` - Session updated
|
|
|
|
### Order
|
|
- `order.created` - Order created (check `billing_reason`)
|
|
- `purchase` - One-time product
|
|
- `subscription_create` - New subscription
|
|
- `subscription_cycle` - Renewal
|
|
- `subscription_update` - Plan change
|
|
- `order.paid` - Payment confirmed
|
|
- `order.updated` - Order updated
|
|
- `order.refunded` - Refund processed
|
|
|
|
### Subscription
|
|
- `subscription.created` - Subscription created
|
|
- `subscription.active` - Subscription activated
|
|
- `subscription.updated` - Subscription modified
|
|
- `subscription.canceled` - Cancellation scheduled
|
|
- `subscription.revoked` - Subscription terminated
|
|
|
|
**Note:** Multiple events may fire for single action
|
|
|
|
### Customer
|
|
- `customer.created` - Customer created
|
|
- `customer.updated` - Customer modified
|
|
- `customer.deleted` - Customer deleted
|
|
- `customer.state_changed` - Benefits/subscriptions changed
|
|
|
|
### Benefit Grant
|
|
- `benefit_grant.created` - Benefit granted
|
|
- `benefit_grant.updated` - Grant modified
|
|
- `benefit_grant.revoked` - Benefit revoked
|
|
|
|
### Refund
|
|
- `refund.created` - Refund initiated
|
|
- `refund.updated` - Refund status changed
|
|
|
|
### Product
|
|
- `product.created` - Product created
|
|
- `product.updated` - Product modified
|
|
|
|
## Event Structure
|
|
|
|
```typescript
|
|
{
|
|
"type": "order.paid",
|
|
"data": {
|
|
"id": "order_xxx",
|
|
"amount": 2000,
|
|
"currency": "USD",
|
|
"billing_reason": "purchase",
|
|
"customer": { ... },
|
|
"product": { ... },
|
|
"subscription": null,
|
|
"metadata": { ... }
|
|
}
|
|
}
|
|
```
|
|
|
|
## Handler Implementation
|
|
|
|
### Basic Handler
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```typescript
|
|
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
|
|
```php
|
|
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
|
|
```bash
|
|
# 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
|
|
```bash
|
|
# Expose local server
|
|
ngrok http 3000
|
|
|
|
# Use ngrok URL in Polar webhook settings
|
|
https://abc123.ngrok.io/webhook/polar
|
|
```
|