Set up and handle Recur webhook events for payment notifications. Use when implementing webhook handlers, verifying signatures, handling subscription events, or when user mentions "webhook", "付款通知", "訂閱事件", "payment notification".
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
You Might Also Like
Related Skills

gog
Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
openclaw
orpc-contract-first
Guide for implementing oRPC contract-first API patterns in Dify frontend. Triggers when creating new API contracts, adding service endpoints, integrating TanStack Query with typed contracts, or migrating legacy service calls to oRPC. Use for all API layer work in web/contract and web/service directories.
langgenius

