Files
2026-04-12 01:06:31 +07:00

8.9 KiB

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

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

{
  "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