Skip to main content

Webhook Infrastructure for SaaS

·StarterPick Team
inngesttrigger-devbullmqbackground-jobssaaswebhooks2026

TL;DR

Inngest for most SaaS builders — serverless-friendly, great DX, handles fan-out and retries without Redis. Trigger.dev for long-running jobs that need durable execution (AI pipelines, data processing, multi-step workflows). BullMQ for high-throughput queues where you already have Redis and need battle-tested reliability. Build your own only if your use case is simple and you can't justify another dependency. The main thing boilerplates miss: they handle webhooks in the route handler itself — that's the wrong pattern. Webhook processing always belongs in a background queue.

Key Takeaways

  • Inngest: serverless-native, no Redis needed, TypeScript-first, free tier generous (50K steps/month)
  • Trigger.dev: durable execution (survives deploys), ideal for multi-hour AI jobs, self-hostable
  • BullMQ: Redis-backed, battle-tested, 1M+ jobs/day capable, requires Redis infrastructure
  • Custom (Postgres queue): zero dependencies, works at modest scale (<10K jobs/day)
  • Never process webhooks synchronously — always enqueue and return 200 immediately
  • Boilerplate gap: ShipFast/T3/Supastarter ship no background job infrastructure

Why Webhooks Need Background Processing

The wrong pattern that most boilerplates ship:

// ❌ WRONG: Processing Stripe webhook synchronously
export async function POST(request: Request) {
  const event = stripe.webhooks.constructEvent(body, sig, secret);

  // This all runs in the HTTP handler — if it fails or times out,
  // Stripe will retry the webhook, causing duplicate processing:
  await db.user.update({ where: { stripeCustomerId: event.data.object.customer } });
  await sendWelcomeEmail(user.email);   // Slow! Email API call in request handler
  await updateMetrics(user);            // Multiple DB writes
  await notifySlack(user);              // External API call

  return new Response('OK');
}

The right pattern:

// ✅ RIGHT: Enqueue immediately, process in background
export async function POST(request: Request) {
  const event = stripe.webhooks.constructEvent(body, sig, secret);

  // Just enqueue — return 200 in under 100ms:
  await inngest.send({ name: 'stripe/webhook', data: { event } });

  return new Response('OK'); // Stripe considers this successful
}

// Background function handles the actual work:
inngest.createFunction(
  { id: 'stripe-webhook-handler' },
  { event: 'stripe/webhook' },
  async ({ event, step }) => {
    // Retried automatically on failure, never blocks the HTTP response
    await step.run('update-user', async () => {
      await db.user.update({ ... });
    });
    await step.run('send-email', async () => {
      await resend.emails.send({ ... });
    });
  }
);

Inngest: Serverless-Native Background Jobs

Best for: Next.js apps on Vercel/serverless, teams that don't want to manage Redis, fan-out patterns

npm install inngest
// lib/inngest.ts
import { Inngest } from 'inngest';

export const inngest = new Inngest({ id: 'my-saas' });
// app/api/inngest/route.ts — single endpoint for all functions
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { handleStripeWebhook } from '@/inngest/stripe';
import { sendWelcomeEmail } from '@/inngest/emails';
import { processAiJob } from '@/inngest/ai';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [handleStripeWebhook, sendWelcomeEmail, processAiJob],
});

Multi-Step Functions

Inngest's killer feature: steps run durably, retrying independently on failure:

// inngest/stripe.ts
import { inngest } from '@/lib/inngest';

export const handleStripeWebhook = inngest.createFunction(
  {
    id: 'stripe-webhook',
    retries: 5,
    throttle: { limit: 100, period: '1m' },
  },
  { event: 'stripe/webhook' },
  async ({ event, step }) => {
    const stripeEvent = event.data.event;

    if (stripeEvent.type !== 'checkout.session.completed') return;

    // Each step retries independently — if step 2 fails, step 1 doesn't re-run:
    const user = await step.run('update-subscription', async () => {
      const session = stripeEvent.data.object;
      return db.user.update({
        where: { id: session.metadata.userId },
        data: {
          plan: 'pro',
          stripeCustomerId: session.customer,
          subscriptionStatus: 'active',
        },
      });
    });

    await step.run('send-welcome-email', async () => {
      await resend.emails.send({
        to: user.email,
        subject: 'Welcome to Pro!',
        html: render(ProWelcomeEmail({ name: user.name })),
      });
    });

    await step.run('notify-slack', async () => {
      await fetch(process.env.SLACK_WEBHOOK_URL!, {
        method: 'POST',
        body: JSON.stringify({ text: `New Pro subscription: ${user.email}` }),
      });
    });
  }
);

Fan-Out Pattern (Parallel Steps)

export const weeklyReportJob = inngest.createFunction(
  { id: 'weekly-reports', concurrency: { limit: 10 } },
  { cron: '0 9 * * MON' },  // Every Monday 9am UTC
  async ({ step }) => {
    // Fetch all users who need reports:
    const users = await step.run('get-users', async () => {
      return db.user.findMany({
        where: { plan: 'pro', weeklyReports: true },
      });
    });

    // Fan out — send one event per user, processed in parallel:
    await step.sendEvent('send-reports', users.map((user) => ({
      name: 'report/weekly',
      data: { userId: user.id },
    })));
  }
);

export const sendWeeklyReport = inngest.createFunction(
  { id: 'send-weekly-report', concurrency: { limit: 20 } },
  { event: 'report/weekly' },
  async ({ event, step }) => {
    const userId = event.data.userId;
    // Process each user's report independently:
    const stats = await step.run('get-stats', async () => {
      return getWeeklyStats(userId);
    });
    await step.run('send-email', async () => {
      await sendReportEmail(userId, stats);
    });
  }
);

Inngest Pricing

Free:          50,000 steps/month
Starter ($25/month): 500,000 steps/month
Growth ($100/month):  5M steps/month

A "step" = one step.run() call or one event send.
A typical Stripe webhook with 3 steps = 3 steps consumed.

Trigger.dev: Durable Long-Running Jobs

Best for: AI pipelines, multi-minute/hour jobs, jobs that must survive deploys

npm install @trigger.dev/sdk@v3
// trigger/ai-pipeline.ts
import { task, wait } from '@trigger.dev/sdk/v3';

export const processDocumentTask = task({
  id: 'process-document',
  maxDuration: 300,  // 5 minutes max
  retry: {
    maxAttempts: 3,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 10000,
    factor: 2,
  },

  run: async (payload: { documentId: string; userId: string }) => {
    const { documentId, userId } = payload;

    // Step 1: Download and extract text
    const document = await fetchDocument(documentId);
    const text = await extractText(document);

    // Step 2: Generate embeddings (slow API call)
    const embeddings = await generateEmbeddings(text);

    // Step 3: Store in vector DB
    await storeEmbeddings(documentId, embeddings);

    // Step 4: Notify user
    await notifyUser(userId, 'Document processed successfully');

    return { documentId, chunkCount: embeddings.length };
  },
});
// Trigger from your API route:
import { tasks } from '@trigger.dev/sdk/v3';
import { processDocumentTask } from '@/trigger/ai-pipeline';

export async function POST(req: Request) {
  const { documentId } = await req.json();
  const session = await auth();

  // Enqueue the job — returns immediately with a handle:
  const handle = await tasks.trigger(processDocumentTask, {
    documentId,
    userId: session!.user.id,
  });

  return Response.json({ runId: handle.id });
}
// Poll job status from client:
import { runs } from '@trigger.dev/sdk/v3';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const runId = searchParams.get('runId')!;

  const run = await runs.retrieve(runId);

  return Response.json({
    status: run.status,  // 'QUEUED' | 'EXECUTING' | 'COMPLETED' | 'FAILED'
    output: run.output,
  });
}

When Trigger.dev Wins Over Inngest

Use Trigger.dev when:
  → Jobs take minutes or hours (AI processing, batch imports)
  → You need to cancel running jobs mid-execution
  → Jobs must survive deploys (CI/CD shouldn't kill in-flight work)
  → You want full self-hosting control
  → Real-time job monitoring is required (streaming logs)

BullMQ: Redis-Backed High-Throughput Queues

Best for: high-volume jobs (1M+/day), teams already using Redis, existing Node.js workers

npm install bullmq ioredis
// lib/queue.ts
import { Queue, Worker, QueueEvents } from 'bullmq';
import Redis from 'ioredis';

const connection = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: null,
});

// Define queues:
export const emailQueue = new Queue('email', { connection });
export const webhookQueue = new Queue('webhooks', { connection });

// Worker processes jobs:
const emailWorker = new Worker(
  'email',
  async (job) => {
    switch (job.name) {
      case 'welcome':
        await sendWelcomeEmail(job.data.userId);
        break;
      case 'weekly-report':
        await sendWeeklyReport(job.data.userId);
        break;
      default:
        throw new Error(`Unknown job type: ${job.name}`);
    }
  },
  {
    connection,
    concurrency: 5,
  }
);

// Error handling:
emailWorker.on('failed', (job, error) => {
  console.error(`Job ${job?.id} failed:`, error);
  // Alert Sentry, update DB status, etc.
});
// Enqueue from API route:
export async function POST(req: Request) {
  const { userId } = await req.json();

  await emailQueue.add('welcome', { userId }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
    removeOnComplete: { count: 1000 },
    removeOnFail: { count: 5000 },
  });

  return new Response('OK');
}
// Scheduled jobs with BullMQ:
import { Queue } from 'bullmq';

await emailQueue.add(
  'weekly-report',
  { batchId: Date.now() },
  {
    repeat: {
      pattern: '0 9 * * MON',  // Every Monday 9am
    },
  }
);

BullMQ requires running a worker process — this is the biggest operational difference from Inngest/Trigger.dev. You need a separate Node.js process (or container) running your workers. On Vercel, you'd need a separate worker service.


Custom: Postgres-Backed Job Queue

Best for: simple use cases, no Redis, modest volume (<10K jobs/day)

// lib/job-queue.ts — minimal Postgres queue
import { db } from './db';

export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed';

export async function enqueueJob(
  type: string,
  data: Record<string, unknown>,
  options?: { scheduledFor?: Date; maxAttempts?: number }
) {
  return db.backgroundJob.create({
    data: {
      type,
      data,
      status: 'pending',
      scheduledFor: options?.scheduledFor ?? new Date(),
      maxAttempts: options?.maxAttempts ?? 3,
    },
  });
}

// Polling worker (run via cron or long-running process):
export async function processNextJob() {
  // Atomic claim using DB transaction:
  const job = await db.$transaction(async (tx) => {
    const job = await tx.backgroundJob.findFirst({
      where: {
        status: 'pending',
        scheduledFor: { lte: new Date() },
        attempts: { lt: tx.backgroundJob.fields.maxAttempts },
      },
      orderBy: { createdAt: 'asc' },
    });

    if (!job) return null;

    return tx.backgroundJob.update({
      where: { id: job.id },
      data: { status: 'processing', startedAt: new Date() },
    });
  });

  if (!job) return false;

  try {
    await executeJob(job);
    await db.backgroundJob.update({
      where: { id: job.id },
      data: { status: 'completed', completedAt: new Date() },
    });
  } catch (error) {
    const nextAttempt = job.attempts + 1;
    const isExhausted = nextAttempt >= job.maxAttempts;

    await db.backgroundJob.update({
      where: { id: job.id },
      data: {
        status: isExhausted ? 'failed' : 'pending',
        attempts: nextAttempt,
        lastError: (error as Error).message,
        // Exponential backoff:
        scheduledFor: isExhausted
          ? undefined
          : new Date(Date.now() + Math.pow(2, nextAttempt) * 5000),
      },
    });
  }

  return true;
}

Which to Choose

InngestTrigger.devBullMQCustom
Setup complexityLowMediumHighLow
Redis required
Serverless compatible
Long-running jobsLimited✅ (hours)Limited
Self-hostablePartial
Volume ceiling5M steps/moCustomUnlimited~10K/day
Monitoring dashboardLimited
Free tier50K steps25K runs--
Choose Inngest if:
  → Vercel/serverless deployment
  → Stripe webhooks, email sending, fan-out patterns
  → Want job monitoring without running infrastructure

Choose Trigger.dev if:
  → AI pipelines or multi-minute jobs
  → Need to cancel in-flight jobs
  → Want full self-hosting control
  → Durable execution across deploys required

Choose BullMQ if:
  → Already have Redis
  → Need 1M+ jobs/day
  → Building on non-serverless infra (Railway, Fly.io, AWS)

Choose Custom (Postgres) if:
  → Simple needs: <10K jobs/day, basic retries
  → Can't add another dependency
  → Already have Postgres (which you do if using Prisma)

Find SaaS boilerplates with pre-built background job infrastructure at StarterPick.

Comments