Webhook Handler Template
When building webhook endpoints, ALWAYS follow this pattern. Webhooks that skip steps = broken payments.
The 5-Step Pattern (applies to ALL providers)
// app/api/webhooks/[provider]/route.ts
export async function POST(req: Request) {
// 1. VERIFY SIGNATURE (never skip)
const body = await req.text();
const signature = req.headers.get('webhook-signature');
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
// 2. PARSE EVENT
const event = JSON.parse(body);
// 3. IDEMPOTENCY CHECK (prevent double-processing)
const eventId = event.id || req.headers.get('webhook-id');
const alreadyProcessed = await checkIdempotency(eventId);
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
// 4. HANDLE EVENT (business logic)
try {
await handleEvent(event);
await markProcessed(eventId);
} catch (error) {
console.error('Webhook processing failed:', error);
// Return 200 anyway to prevent retry storms
// Log to Sentry for investigation
Sentry.captureException(error, { extra: { event } });
}
// 5. RETURN 200 (always, even on error)
return new Response('OK', { status: 200 });
}
Polar.sh (Web Track)
import crypto from 'crypto';
function verifyPolarSignature(body: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Events to handle:
// subscription.created - INSERT/UPDATE subscription in DB, set status='active'
// subscription.updated - UPDATE subscription status
// subscription.cancelled - UPDATE status='cancelled', keep access until period_end
// subscription.revoked - UPDATE status='expired', revoke access immediately
RevenueCat (Mobile Track)
// RevenueCat sends to your Cloud Function or API endpoint
function verifyRevenueCatSignature(body: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// RevenueCat uses Bearer token auth, not HMAC
return signature === `Bearer ${secret}`;
}
// Events to handle:
// INITIAL_PURCHASE - Grant entitlement
// RENEWAL - Extend entitlement
// CANCELLATION - Mark cancelled (still active until expiry)
// EXPIRATION - Revoke entitlement
// BILLING_ISSUE - Show "update payment" banner
Subscription States to Handle
| State | User experience | DB status |
|---|---|---|
| Active | Full access | active |
| Trial | Full access | trial |
| Past due | Access + warning banner | past_due |
| Cancelled | Access until period end | cancelled |
| Expired | No access, show upgrade | expired |
Idempotency (Upstash Redis)
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_URL, token: process.env.UPSTASH_TOKEN });
async function checkIdempotency(eventId: string): Promise<boolean> {
const exists = await redis.get(`webhook:${eventId}`);
return !!exists;
}
async function markProcessed(eventId: string): Promise<void> {
await redis.set(`webhook:${eventId}`, '1', { ex: 86400 }); // 24h TTL
}
Common Mistakes to Prevent
- Returning 500 on error - Provider retries endlessly. Always return 200, log to Sentry
- No signature verification - Anyone can POST fake events to your endpoint
- No idempotency - Network retry = double charge or double grant
- Trusting client-side - Never check payment status from frontend. Always from webhook
- Not handling all states - "cancelled" != "expired". User still has access until period end