Webhook Processing Architecture at Pre-Seed Stage

At pre-seed, your webhook needs are simpler than the full architecture a solo or seed-stage team builds — but the non-negotiables are the same. Signature verification and basic idempotency are required regardless of stage. Everything else is a decision about what to defer until you have the volume that justifies the complexity.

This guide covers the pre-seed webhook architecture: what you must build now, what you can build later, and the two failure modes that will cause real production incidents if you skip them.

📋 Pre-Seed Webhook Requirements

RequirementPre-Seed PriorityReason
Signature verificationRequired immediatelySecurity — anyone can send fake events without it
Basic idempotencyRequired immediatelyAt-least-once delivery means you will receive duplicates
200 response on receiptRequired immediatelyProviders retry if you do not acknowledge quickly
Event type routerBuild earlyPrevents unmanageable switch statements as events grow
Async job queueDefer until neededOnly required when processing exceeds 5 seconds
Retry monitoringDefer until neededAdd when webhook failures cause user-visible problems

🔐 Signature Verification (Do This Today)

Every webhook provider signs their payloads. Always verify the signature before processing. This is a 10-line change and the highest-priority security task in your webhook stack.

For Stripe (the most common pre-seed webhook source):

import Stripe from 'stripe';

export async function POST(req: Request) {
  const body = await req.text(); // Must use raw body
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  await handleEvent(event);
  return new Response('OK', { status: 200 });
}

The critical detail: use the raw request body string, not the parsed JSON. Parsing first will break signature verification because JSON serialization can reorder keys.

🔁 Basic Idempotency

Webhook providers guarantee at-least-once delivery. You will receive the same event more than once — especially if your endpoint returns an error or times out. Without idempotency, a duplicate payment.succeeded event can provision two accounts, send two confirmation emails, or apply a credit twice.

The pre-seed idempotency solution is a single database table:

CREATE TABLE processed_webhooks (
  event_id    TEXT PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);

async function handleEvent(event) {
  // Try to insert — fails on duplicate
  try {
    await db.query(
      'INSERT INTO processed_webhooks (event_id) VALUES ($1)',
      [event.id]
    );
  } catch (err) {
    if (err.code === '23505') return; // Already processed
    throw err;
  }

  // Process the event
  await processEvent(event);
}

This is atomic (the database uniqueness constraint acts as the lock), requires no additional infrastructure, and handles concurrent requests correctly.

⚡ Synchronous vs Async Processing at Pre-Seed

At pre-seed, process webhooks synchronously unless you have a specific operation that takes more than 5 seconds. Sending a welcome email, updating an account record, and writing an audit log entry are all fast enough to do synchronously within the webhook request cycle.

Add a job queue (Inngest, BullMQ) when you have:

At pre-seed, none of these conditions are usually met. Synchronous processing is simpler, easier to debug, and correct for small volumes. Optimize when you have a problem, not before.

What to Do Next

If your webhook endpoint has no signature verification: add it before your next production deploy. It is the highest-priority security fix in your stack and takes 10 minutes. If you have verification but no idempotency: add the processed_webhooks table and the duplicate check before your next incident. If you have both: verify that you return 200 for unrecognized event types (return 400 only for signature failures, not for events you do not handle — returning 400 causes some providers to retry indefinitely).