DocumentationGuidesWebhook Processing

Webhook Processing

Handle incoming webhooks reliably with background jobs.

Why Use Background Jobs for Webhooks?

Webhook endpoints should respond quickly. Processing in the background:

  • ✅ Returns 200 immediately (prevents retries from the sender)
  • ✅ Handles failures gracefully with retries
  • ✅ Scales processing independently

Basic Pattern

jobs/process-webhook.ts
import { pidgey } from '@/lib/pidgey';
 
export const processWebhook = pidgey.defineJob({
  name: 'process-webhook',
  handler: async (data: { event: string; payload: unknown }) => {
    switch (data.event) {
      case 'payment.succeeded':
        await handlePaymentSuccess(data.payload);
        break;
      case 'subscription.cancelled':
        await handleCancellation(data.payload);
        break;
      default:
        console.log('Unknown event:', data.event);
    }
  },
  config: {
    retries: 5, // Retry processing failures
  },
});
app/api/webhooks/stripe/route.ts
import { processWebhook } from '@/jobs/process-webhook';
 
export async function POST(request: Request) {
  const payload = await request.json();
  const event = payload.type;
 
  // Enqueue and respond immediately
  await processWebhook.enqueue({ event, payload });
 
  return new Response('OK', { status: 200 });
}

Stripe Example

jobs/stripe-webhook.ts
export const handleStripeWebhook = pidgey.defineJob({
  name: 'stripe-webhook',
  handler: async (data: { event: Stripe.Event }) => {
    const { event } = data;
 
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        await db.orders.update(session.metadata.orderId, {
          paymentStatus: 'paid',
        });
        break;
      }
 
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await db.subscriptions.cancel(subscription.id);
        break;
      }
    }
  },
  config: {
    retries: 3,
    timeout: 30000,
  },
});

Idempotency

⚠️

Webhooks may be delivered multiple times. Make your handlers idempotent.

handler: async (data: { eventId: string; payload: unknown }) => {
  // Check if already processed
  const existing = await db.processedEvents.findUnique({
    where: { id: data.eventId },
  });
 
  if (existing) {
    console.log('Already processed:', data.eventId);
    return;
  }
 
  // Process the event
  await processEvent(data.payload);
 
  // Mark as processed
  await db.processedEvents.create({
    data: { id: data.eventId },
  });
};

Multiple Webhook Sources

Use different queues for different sources:

export const githubWebhook = pidgey.defineJob({
  name: 'github-webhook',
  handler: async (data) => {
    /* ... */
  },
  config: { queue: 'github' },
});
 
export const stripeWebhook = pidgey.defineJob({
  name: 'stripe-webhook',
  handler: async (data) => {
    /* ... */
  },
  config: { queue: 'stripe' },
});