Skip to main content
Backend & APIs
⚙️ Backend & APIsLesson 10 of 13

Webhooks & Third-Party Integrations

Talk to other services and receive their events safely, with verified webhooks.

Webhooks & Third-Party Integrations

Stage 3 · Backend & APIs · B.U.I.L.D. letter: L

Every webhook endpoint you expose without signature verification is an unlocked back door — and every third-party API call you fire without a timeout is a loaded gun pointed at your server's uptime.


⚠️ The vibe trap

You vibe-coded a Stripe webhook handler, deployed it, and it works. But you never verify the Stripe-Signature header — so anyone can POST fake payment events to your /webhook endpoint and your app will treat them as real. The second trap: you dropped your Stripe secret key directly into a React component so the front end could "just call Stripe directly." Now your key is in every user's browser, in every git clone, and on every public bundle-analyzer screenshot tweeted about your site. Third-party integrations are where vibe-coded apps get pwned.


🌐 Consuming a Third-Party API — The Server-Side-Only Rule

Your API keys are credentials, not config. They never belong in a browser bundle, a React component, or a .env.local file that gets committed. The rule is absolute: all calls to third-party APIs happen on your server, behind an endpoint your front end calls. The browser gets a result; it never sees the key.

Beyond key safety, calling third-party APIs from a server lets you enforce timeouts, retry with backoff, and surface clean errors to your users rather than leaking a raw 500 from some vendor's service.

// server/lib/geocode.js
// Wraps a third-party geocoding API — key stays server-side, never exposed.

const GEOCODE_KEY = process.env.GEOCODE_API_KEY; // server env only
const GEOCODE_URL = 'https://api.example-geocoder.com/v1/json';

export async function geocodeAddress(address) {
  const controller = new AbortController();
  // 5-second hard timeout — their service is NOT your problem to wait for
  const timeoutId = setTimeout(() => controller.abort(), 5000);

  let res;
  try {
    res = await fetch(`${GEOCODE_URL}?address=${encodeURIComponent(address)}&key=${GEOCODE_KEY}`, {
      signal: controller.signal,
    });
  } catch (err) {
    if (err.name === 'AbortError') {
      // Timeout — treat it as a transient failure your caller can retry
      throw new Error('Geocoding service timed out. Try again.');
    }
    // Network error — their service may be down
    throw new Error('Geocoding service unreachable.');
  } finally {
    clearTimeout(timeoutId);
  }

  if (res.status === 429) {
    // Their rate limit — do NOT crash your user, surface a retryable error
    throw new Error('Geocoding rate limit hit. Retry after a moment.');
  }

  if (!res.ok) {
    // Their 4xx/5xx — log for ops, return a clean message
    console.error(`[geocode] upstream error ${res.status}`);
    throw new Error('Could not geocode address right now.');
  }

  const data = await res.json();
  return data.results[0]; // caller's problem if the address has no result
}

Mental model: Pretend the third-party service will go down at the worst possible moment — because it will. Your code must degrade gracefully every time. Set a timeout, catch every error branch, and never let their failure propagate as an unhandled crash to your users.

Why it matters: Without AbortController, a slow upstream can hold an open connection until your Node process is saturated with pending requests. One slow vendor + no timeout = your entire app becomes unresponsive.

Common mistake: Putting the API key in a NEXT_PUBLIC_ env variable (or any client-prefixed env). That ships the key to the browser. Always use unprefixed server-only env vars and keep the call in a route handler, Edge Function, or server action.


🔔 Receiving Webhooks — Events Coming TO You

A webhook is the reverse of an API call: instead of you asking "did anything happen?", they POST to your server the moment something happens. Stripe fires payment_intent.succeeded. GitHub fires push. Twilio fires message.received. Your endpoint is live on the internet and reachable by anyone — which is exactly the attack surface.

// server/routes/webhooks/stripe.js  (Express example)
import express from 'express';
export const stripeWebhookRouter = express.Router();

// CRITICAL: parse raw body — Stripe's signature check needs the exact bytes,
// not a re-serialised JSON object. Mount this BEFORE express.json().
stripeWebhookRouter.post(
  '/stripe',
  express.raw({ type: 'application/json' }),
  handleStripeWebhook
);

async function handleStripeWebhook(req, res) {
  // Step 1: verify first, always. Return 400 before doing ANY work.
  // (Verification shown in the next section.)
  const event = verifyStripeSignature(req); // throws on failure
  if (!event) return res.status(400).send('Bad signature');

  // Step 2: acknowledge immediately — Stripe retries if you don't respond fast.
  // Business logic goes async; the 200 goes out now.
  res.status(200).send('received');

  // Step 3: handle async so slow DB writes don't cause a timeout+retry storm
  await processStripeEventAsync(event);
}

Mental model: Your webhook endpoint is a mail slot. You want to grab the envelope, stamp it "received," and only then open it in the back room. If you make Stripe wait while you run a slow DB query, Stripe will time out, assume you failed, and send the event again — and again.

Why it matters: Stripe considers a webhook delivery failed if you don't respond within ~30 seconds. SendGrid, GitHub, and most providers are similar. Slow processing = duplicate deliveries = corrupted state unless you also dedupe (see below).

Common mistake: Putting await runSlowBusinessLogic(event) before res.status(200).send(). This blocks the response, causes retries, and produces duplicates even with signature verification.


🔐 Verifying Webhook Signatures — HMAC or Nothing

A POST request arriving at /webhook/stripe could come from Stripe. It could also come from a teenager with curl who found your endpoint in a JavaScript bundle. The only way to tell the difference is a cryptographic signature. Every serious provider (Stripe, GitHub, Twilio, Shopify) includes one. Checking it is mandatory — full stop.

The pattern: the provider hashes the raw request body with a shared secret using HMAC-SHA256 and puts the result in a header. You re-compute the same hash and compare. If they don't match, you return 400 and do nothing else.

// server/lib/verify-stripe-signature.js
import crypto from 'node:crypto';

const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; // from Stripe Dashboard

/**
 * Verifies the Stripe-Signature header and returns the parsed event.
 * Throws on invalid signature or expired timestamp.
 */
export function verifyStripeSignature(req) {
  const signatureHeader = req.headers['stripe-signature'];
  if (!signatureHeader) throw new Error('Missing Stripe-Signature header');

  // Stripe encodes: t=<timestamp>,v1=<sig1>,v1=<sig2...>
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('='))
  );
  const timestamp = parts['t'];
  const receivedSig = parts['v1'];

  if (!timestamp || !receivedSig) throw new Error('Malformed signature header');

  // Replay attack protection: reject events older than 5 minutes
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (ageSeconds > 300) throw new Error('Webhook timestamp too old — possible replay');

  // Re-compute the expected signature
  const signedPayload = `${timestamp}.${req.body}`; // req.body is raw Buffer here
  const expectedSig = crypto
    .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');

  // Constant-time comparison prevents timing attacks
  const sigBuffer = Buffer.from(receivedSig, 'hex');
  const expectedBuffer = Buffer.from(expectedSig, 'hex');

  if (
    sigBuffer.length !== expectedBuffer.length ||
    !crypto.timingSafeEqual(sigBuffer, expectedBuffer)
  ) {
    throw new Error('Signature mismatch — request rejected');
  }

  return JSON.parse(req.body.toString());
}

Mental model: Their request is hostile until proven authentic. The signature is the proof. No proof, no processing — not even a database read.

Why timingSafeEqual? A normal === string comparison returns early the moment it finds a mismatch. An attacker can measure how long your comparison takes and brute-force the signature one byte at a time. timingSafeEqual always takes the same amount of time regardless of where the mismatch occurs.

Common mistake: Parsing req.body with express.json() before verifying the signature. JSON parsing normalises whitespace and key order, so your re-computed hash won't match. Always parse the raw bytes first, verify, then JSON-parse.


♻️ Idempotent Webhook Handling — Deduplicate or Die

Providers retry. Stripe will retry a webhook up to 87 times over three days if it doesn't get a 2xx. GitHub retries on network errors. This is a feature — but it means your handler will receive the same event more than once. If you process "charge.succeeded" twice, you ship the order twice, give the credit twice, or send two welcome emails.

The fix is idempotent processing: track every event ID you've seen. If you see it again, return 200 immediately and do nothing.

// server/routes/webhooks/process-async.js
import { db } from '../db.js'; // your DB client

/**
 * Process a Stripe event exactly once, using the event ID as a dedup key.
 */
export async function processStripeEventAsync(event) {
  // Attempt to insert the event ID as a unique record.
  // If it already exists, the INSERT will fail — that's our dedup signal.
  const inserted = await db.query(
    `INSERT INTO processed_webhook_events (event_id, processed_at)
     VALUES ($1, NOW())
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id]
  );

  if (inserted.rowCount === 0) {
    // Already processed — safe to ignore. Log for observability.
    console.info(`[webhook] duplicate event ignored: ${event.id}`);
    return;
  }

  // First time seeing this event — handle it
  switch (event.type) {
    case 'payment_intent.succeeded':
      await fulfillOrder(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await downgradeUser(event.data.object.customer);
      break;
    default:
      // Unhandled event type — that's fine, just log it
      console.info(`[webhook] unhandled event type: ${event.type}`);
  }
}

The processed_webhook_events table needs one column — event_id TEXT PRIMARY KEY — and a unique index (the primary key gives you that). The ON CONFLICT DO NOTHING pattern is atomic in Postgres: even two concurrent deliveries of the same event will only process once.

Mental model: Your webhook handler should be safe to call ten times with the same event. Write it that way from day one, because retries are guaranteed, not theoretical.

Why it matters: Without deduplication, a 30-second DB write that causes Stripe to retry will result in two fulfilled orders. The processed_webhook_events table costs almost nothing to maintain and saves you from the worst class of data corruption bugs.

Common mistake: Using an in-memory Set or a Redis key with a short TTL as your dedup store. Stripe retries for up to 3 days. In-memory state is lost on restart. Use a durable store (Postgres, your main DB) with a long enough retention to cover the provider's retry window.


🛠️ Your Mission

You have a working /webhook/stripe endpoint that receives events and logs them, but it has three security and reliability problems:

  1. It trusts the payload without verifying Stripe-Signature.
  2. It runs all business logic synchronously before sending the 200 response.
  3. It has no dedup protection — duplicate deliveries corrupt your data.

Your task: Harden the endpoint.

  1. Pull the raw body using express.raw() before express.json() for the webhook route only.
  2. Implement verifyStripeSignature(req) using the HMAC pattern above. If verification fails, return 400 immediately and log a warning.
  3. Move your business logic into processStripeEventAsync(event) and call it after sending the 200.
  4. Add a processed_webhook_events table and wrap your handler logic in the ON CONFLICT DO NOTHING dedup pattern.
  5. Add a 5-second AbortController timeout to every outbound third-party fetch call in your codebase.

Stretch: Test your signature verification by generating a valid HMAC in a test script (using your local webhook secret) and asserting the endpoint accepts it, then mutating one byte of the payload and asserting the endpoint returns 400.


✅ You're done when…

  • The Security Audit checklist entry "webhook signature verified before any processing" passes — no code path reaches business logic without a confirmed valid HMAC signature.
  • The Production-Readiness Checklist entry "outbound API calls have timeouts" passes — every fetch to a third-party has an AbortController timeout of 5–10 seconds.
  • Sending the same Stripe event ID twice to your endpoint results in exactly one fulfilled action (confirmed via processed_webhook_events row count = 1 after two identical POSTs).
  • No API keys appear in any file under src/ that is imported by a browser bundle — all third-party calls originate from server-side code only.

➡️ Next: API Versioning & Evolution. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.