DocumentationGuidesWebhook Processing

Webhook Processing

Handle incoming webhooks reliably by moving all heavy work into background jobs.

Why Use Background Jobs for Webhooks?

Webhook endpoints should respond quickly. Processing webhooks in the background:

  • ✅ Returns 200 OK immediately (prevents provider retries)
  • ✅ Handles failures with automatic retries
  • ✅ Scales independently from your API layer

1. Basic Pattern

Define a generic job to process webhook events:

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('Unhandled event:', data.event);
    }
  },
  config: {
    retries: 5,
  },
});

Enqueue the job inside your webhook route and respond immediately:

app/api/webhooks/stripe/route.ts
import { processWebhook } from '@/jobs/process-webhook';
 
export async function POST(request: Request) {
  const payload = await request.json();
 
  await processWebhook.enqueue({
    event: payload.type,
    payload,
  });
 
  return new Response('OK', { status: 200 });
}

2. Stripe Example

Use a dedicated job for Stripe events:

jobs/stripe-webhook.ts
export const stripeWebhook = 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: 30_000,
  },
});

3. Idempotency

Webhook providers may deliver the same event multiple times.

Use Pidgey’s built-in idempotencyKey at enqueue time to prevent duplicate jobs:

app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const payload = await request.json();
 
  await processWebhook.enqueue(
    { event: payload.type, payload },
    {
      // Stripe event IDs are globally unique
      idempotencyKey: payload.id,
    }
  );
 
  return new Response('OK', { status: 200 });
}

Idempotency keys prevent duplicate job creation, not duplicate side effects. Handlers should still be written to be safe on retry.

4. Multiple Webhook Sources

Use separate queues to isolate workloads from different providers:

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',
  },
});

Dedicated queues prevent one slow or noisy provider from blocking others.

Best Practices

  • Respond immediately — enqueue the job and return 200 OK
  • Use idempotency keys — especially for payment and billing events
  • Configure retries — based on how critical the event is
  • Set timeouts — to avoid blocking worker capacity
  • Separate queues — by provider or priority when needed

Next Steps

  • Jobs — Define and configure background jobs
  • Worker — Run and scale workers
  • Deployment — Deploy workers to production

Made with ❤️ in Chicago