Polar ensures reliable webhook delivery with automatic retries and provides signature verification to secure your webhook endpoints.
Delivery Guarantees
At-Least-Once Delivery
Polar guarantees at-least-once delivery for webhook events. This means:
- Every event will be delivered to your endpoint at least once
- Events may be delivered more than once in rare cases (e.g., network issues, retries)
- Your endpoint should be idempotent - processing the same event multiple times should be safe
Ordering Guarantee
Polar delivers events in order for each endpoint:
- Events are delivered in the order they were created
- A newer event won’t be delivered until earlier events succeed
- This is enforced with a configurable age limit to prevent indefinite blocking
If an event is older than the age limit (configured in settings) and earlier events are still pending, the newer event will be delivered anyway to prevent indefinite delays.
Retry Logic
Success Criteria
A webhook delivery is considered successful when:
- Your endpoint returns an HTTP status code in the 2xx range (200-299)
- The response is received within 10 seconds
Failure Scenarios
A delivery fails if:
- Non-2xx status code is returned (3xx, 4xx, 5xx)
- Request times out (>10 seconds)
- Connection cannot be established
- TLS/SSL errors occur
Retry Schedule
Polar automatically retries failed deliveries with exponential backoff:
- Immediate: First retry after a few seconds
- Gradual: Subsequent retries with increasing delays
- Maximum attempts: Up to 10 retries per event
Example retry schedule:
- Attempt 1: Immediate
- Attempt 2: ~30 seconds
- Attempt 3: ~1 minute
- Attempt 4: ~5 minutes
- Attempt 5: ~15 minutes
- …up to attempt 10
After 10 consecutive failed deliveries, the webhook endpoint is automatically disabled. See Managing Endpoints for details.
Signature Verification
All webhooks sent by Polar include cryptographic signatures to verify authenticity. You should always verify these signatures to ensure the webhook came from Polar.
How It Works
Polar uses the Standard Webhooks specification:
-
Each webhook request includes these headers:
webhook-id: Unique event ID
webhook-timestamp: Unix timestamp when the webhook was sent
webhook-signature: HMAC signature of the payload
-
The signature is computed using HMAC-SHA256 with your webhook secret
-
The signed message format:
{webhook-id}.{webhook-timestamp}.{payload}
Verification Libraries
Use the official Standard Webhooks libraries for secure verification:
Python
from fastapi import Request, HTTPException
from svix.webhooks import Webhook, WebhookVerificationError
WEBHOOK_SECRET = "polar_whs_..." # From webhook endpoint creation
@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 as e:
raise HTTPException(status_code=401, detail="Invalid signature")
# Process the verified payload
event_type = payload["type"]
event_data = payload["data"]
if event_type == "order.paid":
# Grant access to customer
await grant_access(event_data["customer"]["email"])
return {"ok": True}
Node.js / TypeScript
import { Webhook } from "standardwebhooks";
import { Request, Response } from "express";
const WEBHOOK_SECRET = "polar_whs_..."; // From webhook endpoint creation
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,
});
// Process the verified payload
if (payload.type === "order.paid") {
await grantAccess(payload.data.customer.email);
}
res.json({ ok: true });
} catch (err) {
return res.status(401).json({ error: "Invalid signature" });
}
});
package main
import (
"encoding/json"
"io"
"net/http"
"github.com/standard-webhooks/standard-webhooks/libraries/go"
)
const webhookSecret = "polar_whs_..." // From webhook endpoint creation
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
wh, err := webhooks.NewWebhook(webhookSecret)
if err != nil {
http.Error(w, "Invalid webhook secret", http.StatusInternalServerError)
return
}
headers := http.Header{}
headers.Set("webhook-id", r.Header.Get("webhook-id"))
headers.Set("webhook-timestamp", r.Header.Get("webhook-timestamp"))
headers.Set("webhook-signature", r.Header.Get("webhook-signature"))
var payload map[string]interface{}
err = wh.Verify(body, headers)
if err != nil {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
json.Unmarshal(body, &payload)
// Process the verified payload
if payload["type"] == "order.paid" {
// Grant access
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
Manual Verification
If you can’t use the Standard Webhooks library, you can verify manually:
import hmac
import hashlib
import base64
def verify_webhook_signature(
payload: bytes,
webhook_id: str,
webhook_timestamp: str,
webhook_signature: str,
secret: str
) -> bool:
# Construct the signed message
signed_content = f"{webhook_id}.{webhook_timestamp}.{payload.decode()}"
# Decode the secret (it's base64 encoded)
secret_bytes = base64.b64decode(secret)
# Compute HMAC-SHA256
expected_signature = hmac.new(
secret_bytes,
signed_content.encode(),
hashlib.sha256
).digest()
# The signature in the header is base64 encoded
expected_signature_b64 = base64.b64encode(expected_signature).decode()
# Extract the signature from the header (format: "v1,<signature>")
# Standard Webhooks uses versioned signatures
signature_parts = webhook_signature.split(",")
for part in signature_parts:
if part.startswith("v1,"):
provided_signature = part[3:] # Remove "v1," prefix
return hmac.compare_digest(provided_signature, expected_signature_b64)
return False
Security Best Practices:
- Always verify webhook signatures before processing
- Use constant-time comparison (
hmac.compare_digest) to prevent timing attacks
- Validate the timestamp to prevent replay attacks (reject webhooks older than 5 minutes)
- Store webhook secrets securely in environment variables
- Never log or expose webhook secrets
Monitoring Deliveries
Via Dashboard
- Go to Settings → Webhooks
- Click “Deliveries” tab
- Filter by:
- Endpoint
- Event type
- Success/failure status
- HTTP status code
- Time range
Via API
deliveries = client.webhooks.list_deliveries(
endpoint_id="whe_...",
succeeded=False, # Filter failed deliveries
http_code_class="5xx",
start_timestamp="2024-03-15T00:00:00Z"
)
for delivery in deliveries.items:
print(f"{delivery.created_at}: {delivery.http_code} - {delivery.response}")
Redelivering Events
You can manually redeliver failed events:
Via Dashboard
- Go to Settings → Webhooks → Deliveries
- Find the failed delivery
- Click “Redeliver”
Via API
client.webhooks.redeliver_event(id="whe_...")
Redelivery bypasses the ordering guarantee and sends the event immediately, even if earlier events are still pending.
Best Practices
Endpoint Design
✅ Do:
- Return 2xx status codes quickly (within 10 seconds)
- Queue events for asynchronous processing
- Make your handler idempotent
- Log webhook IDs to detect duplicates
- Verify signatures on every request
- Use HTTPS endpoints
❌ Don’t:
- Perform long-running operations synchronously
- Return non-2xx for processing errors (use 2xx and handle errors async)
- Trust webhook data without signature verification
- Expose webhook endpoints without authentication
Idempotency Pattern
import asyncpg
async def process_webhook(webhook_id: str, payload: dict):
async with db_pool.acquire() as conn:
# Check if we've already processed this webhook
existing = await conn.fetchrow(
"SELECT id FROM processed_webhooks WHERE webhook_id = $1",
webhook_id
)
if existing:
# Already processed, skip
return {"ok": True, "status": "duplicate"}
# Process the webhook
if payload["type"] == "order.paid":
await grant_access(payload["data"]["customer"]["email"])
# Mark as processed
await conn.execute(
"INSERT INTO processed_webhooks (webhook_id, processed_at) VALUES ($1, NOW())",
webhook_id
)
return {"ok": True, "status": "processed"}
Error Handling
@app.post("/webhooks")
async def webhook_handler(request: Request):
try:
# Verify signature first
payload = await verify_webhook(request)
# Queue for processing (don't process synchronously)
await webhook_queue.enqueue(payload)
# Return success immediately
return {"ok": True}
except WebhookVerificationError:
# Invalid signature - reject
raise HTTPException(status_code=401)
except Exception as e:
# Log error but still return 2xx to prevent retries
# (the event is queued, so it will be processed)
logger.error(f"Failed to queue webhook: {e}")
return {"ok": True, "queued": False}