Recur Webhook Integration
You are helping implement Recur webhooks to receive real-time payment and subscription events.
Webhook Events
Core Events (Most Common)
| Event | When Fired |
|---|---|
checkout.completed | Payment successful, subscription/order created |
subscription.activated | Subscription is now active |
subscription.cancelled | Subscription was cancelled |
subscription.renewed | Recurring payment successful |
subscription.past_due | Payment failed, subscription at risk |
order.paid | One-time purchase completed |
refund.created | Refund initiated |
All Supported Events
type WebhookEventType =
// Checkout
| 'checkout.created'
| 'checkout.completed'
// Orders
| 'order.paid'
| 'order.payment_failed'
// Subscription Lifecycle
| 'subscription.created'
| 'subscription.activated'
| 'subscription.cancelled'
| 'subscription.expired'
| 'subscription.trial_ending'
// Subscription Changes
| 'subscription.upgraded'
| 'subscription.downgraded'
| 'subscription.renewed'
| 'subscription.past_due'
// Scheduled Changes
| 'subscription.schedule_created'
| 'subscription.schedule_executed'
| 'subscription.schedule_cancelled'
// Invoices
| 'invoice.created'
| 'invoice.paid'
| 'invoice.payment_failed'
// Customer
| 'customer.created'
| 'customer.updated'
// Product
| 'product.created'
| 'product.updated'
// Refunds
| 'refund.created'
| 'refund.succeeded'
| 'refund.failed'
Webhook Handler Implementation
Next.js App Router
// app/api/webhooks/recur/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
const WEBHOOK_SECRET = process.env.RECUR_WEBHOOK_SECRET!
function verifySignature(payload: string, signature: string): boolean {
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
export async function POST(request: NextRequest) {
const payload = await request.text()
const signature = request.headers.get('x-recur-signature')
// Verify signature
if (!signature || !verifySignature(payload, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const event = JSON.parse(payload)
// Handle events
switch (event.type) {
case 'checkout.completed':
await handleCheckoutCompleted(event.data)
break
case 'subscription.activated':
await handleSubscriptionActivated(event.data)
break
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data)
break
case 'subscription.renewed':
await handleSubscriptionRenewed(event.data)
break
case 'subscription.past_due':
await handleSubscriptionPastDue(event.data)
break
case 'refund.created':
await handleRefundCreated(event.data)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
}
// Event handlers
async function handleCheckoutCompleted(data: any) {
const { customerId, subscriptionId, orderId, productId, amount } = data
// Update your database
// Grant access to the user
// Send confirmation email
}
async function handleSubscriptionActivated(data: any) {
const { subscriptionId, customerId, productId, status } = data
// Update user's subscription status in your database
// Enable premium features
}
async function handleSubscriptionCancelled(data: any) {
const { subscriptionId, customerId, cancelledAt, accessUntil } = data
// Mark subscription as cancelled
// User still has access until accessUntil date
// Send cancellation confirmation email
}
async function handleSubscriptionRenewed(data: any) {
const { subscriptionId, customerId, amount, nextBillingDate } = data
// Update billing records
// Extend access period
}
async function handleSubscriptionPastDue(data: any) {
const { subscriptionId, customerId, failureReason } = data
// Notify user of payment failure
// Consider sending dunning emails
// May want to restrict access after grace period
}
async function handleRefundCreated(data: any) {
const { refundId, orderId, amount, reason } = data
// Update order status
// Adjust user credits/access
// Send refund notification
}
Express.js
import express from 'express'
import crypto from 'crypto'
const app = express()
// Important: Use raw body for signature verification
app.post(
'/api/webhooks/recur',
express.raw({ type: 'application/json' }),
(req, res) => {
const payload = req.body.toString()
const signature = req.headers['x-recur-signature'] as string
// Verify signature
const expected = crypto
.createHmac('sha256', process.env.RECUR_WEBHOOK_SECRET!)
.update(payload)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(payload)
// Handle event...
console.log('Received event:', event.type)
res.json({ received: true })
}
)
Event Payload Structure
interface WebhookEvent {
id: string // Event ID (for idempotency)
type: string // Event type
timestamp: string // ISO 8601 timestamp
data: {
// Varies by event type
customerId?: string
customerEmail?: string
subscriptionId?: string
orderId?: string
productId?: string
amount?: number
currency?: string
// ... more fields depending on event
}
}
Webhook Configuration
- Go to Recur Dashboard → Settings → Webhooks
- Click Add Endpoint
- Enter your endpoint URL (e.g.,
https://yourapp.com/api/webhooks/recur) - Select events to receive
- Copy the Webhook Secret to your environment variables
Testing Webhooks Locally
Using ngrok
# Start ngrok tunnel
ngrok http 3000
# Use the ngrok URL in Recur dashboard
# https://xxxx.ngrok.io/api/webhooks/recur
Using Recur CLI (if available)
# Forward webhooks to local server
recur webhooks forward --to localhost:3000/api/webhooks/recur
Best Practices
1. Always Verify Signatures
Never trust webhook payloads without verifying the signature.
2. Handle Idempotency
Webhooks may be delivered multiple times. Use the event id to deduplicate:
async function handleEvent(event: WebhookEvent) {
// Check if already processed
const existing = await db.webhookEvent.findUnique({
where: { eventId: event.id }
})
if (existing) {
console.log('Event already processed:', event.id)
return
}
// Process event...
// Mark as processed
await db.webhookEvent.create({
data: { eventId: event.id, processedAt: new Date() }
})
}
3. Return 200 Quickly
Process events asynchronously to avoid timeouts:
export async function POST(request: NextRequest) {
// Verify and parse...
// Queue for async processing
await queue.add('process-webhook', event)
// Return immediately
return NextResponse.json({ received: true })
}
4. Handle Retries Gracefully
Recur retries failed webhook deliveries. Ensure your handler is idempotent.
5. Log Everything
console.log('Webhook received:', {
type: event.type,
id: event.id,
timestamp: event.timestamp,
})
Debugging Webhooks
Check Webhook Logs
In Recur Dashboard → Webhooks → Click endpoint → View delivery logs
Common Issues
401 Unauthorized
- Check webhook secret is correct
- Ensure using raw body for signature verification
- Verify signature algorithm (HMAC SHA-256)
Timeout (no response)
- Return 200 before processing
- Use async processing for heavy operations
Missing events
- Check event types are selected in dashboard
- Verify endpoint URL is correct and accessible
Related Skills
/recur-quickstart- Initial SDK setup/recur-checkout- Implement payment flows/recur-entitlements- Check subscription access after webhook