skills/recur-tw/skills/recur-webhooks

recur-webhooks

SKILL.md

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

  1. Go to Recur DashboardSettingsWebhooks
  2. Click Add Endpoint
  3. Enter your endpoint URL (e.g., https://yourapp.com/api/webhooks/recur)
  4. Select events to receive
  5. 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
Weekly Installs
26
Repository
recur-tw/skills
First Seen
Jan 22, 2026
Installed on
claude-code21
codex20
opencode20
gemini-cli20
github-copilot18
amp15