Files
english/.opencode/skills/payment-integration/references/polar/webhooks.md
2026-04-12 01:06:31 +07:00

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
```