Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Webhook Testing: Inspecting Payloads Before You Write the Handler

Sean

Platform Writer

Aug 10, 2026
8 min read

The fastest way to see what a webhook actually sends is to point it at a request bin — a throwaway URL that captures the full request and shows you headers, body, and query string. Do that before writing a line of handler code, because the payload is almost never exactly what the documentation says it is.

Webhook Testing: Inspecting Payloads Before You Write the Handler

Webhook integrations fail in a predictable order: first you cannot see the payload, then you cannot reach localhost, then you trust a request you should not have, then you process the same event twice. Each has a standard solution, and knowing them turns a two-day integration into an afternoon.

Table of contents

Step one: capture the real payload

A request bin generates a unique URL and records everything sent to it. Paste that URL into the provider’s webhook settings, trigger the event, and read what arrives.

What you are looking for, and what documentation routinely gets wrong:

  • The actual body shape — nesting, field names, and which fields are absent rather than null.
  • Content-Type — JSON is common but form-encoded still exists, and it changes how you parse.
  • Signature headers — the exact header name and encoding, which you need for verification.
  • Event type headers — many providers put the event name in a header rather than the body.
  • Retry headers — a delivery ID and attempt count, which is what makes deduplication possible.

You can run the same thing yourself in a few lines, which is worth doing for anything sensitive — a third-party bin sees your payloads, and if you are testing against real data that is a genuine consideration.

import express from 'express';
const app = express();

// Raw body, so signatures can be verified later
app.use(express.raw({ type: '*/*' }));

app.all('*', (req, res) => {
  console.log('---', new Date().toISOString());
  console.log(req.method, req.originalUrl);
  console.log(JSON.stringify(req.headers, null, 2));
  console.log(req.body.toString('utf8'));
  res.status(200).json({ received: true });
});

app.listen(process.env.PORT || 3000);

Capture the raw body, not the parsed one. Signature verification hashes the exact bytes that were sent, and a JSON round-trip through your parser will change whitespace and key order enough to break the comparison.

Step two: getting webhooks to localhost

Providers cannot reach your laptop. A tunnel gives your local server a public URL for the duration of the session.

# Cloudflare Tunnel -- no account needed for a quick test
cloudflared tunnel --url http://localhost:3000

# ngrok
ngrok http 3000

# Plain SSH reverse tunnel, if you have a server with a public IP
ssh -R 8080:localhost:3000 [email protected]

The SSH option is worth remembering when you already have a server, because it requires installing nothing and the URL is stable as long as you control the remote side.

Tunnels are for development. Do not build a production integration on one — the URL changes on restart with most free tiers, and you have inserted a third party into your delivery path for no benefit.

Many providers also offer a replay button in their dashboard, which is more useful than a tunnel once you are past the discovery phase: capture one real event, then replay it against your handler as many times as you need.

Step three: verify the signature, always

A webhook endpoint is a public URL that performs privileged actions. Without verification, anyone who guesses it can tell you a payment succeeded.

The standard scheme is HMAC: the provider signs the raw body with a shared secret and sends the digest in a header. You recompute and compare.

import crypto from 'node:crypto';

function verify(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // Buffer, not a re-serialised object
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signatureHeader, 'utf8');

  // Length check first: timingSafeEqual throws on a mismatch
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Use a constant-time comparison. A plain === on strings returns as soon as it finds a differing character, which leaks how much of the signature was correct. It is a real attack, and the fix is one function call.

Where the provider includes a timestamp in the signed payload, check it too — reject anything older than about five minutes. Otherwise a captured valid request can be replayed indefinitely.

The secret belongs in an environment variable on the service, the same as any other credential.

Step four: assume duplicates, because there will be duplicates

Webhook delivery is at-least-once. If your handler is slow, or returns a 500, or the connection drops after you processed the event but before the response arrived, the provider retries. You will receive the same event twice.

The fix is a delivery-ID table with a uniqueness constraint, letting the database enforce what your code cannot.

CREATE TABLE webhook_events (
  id            BIGSERIAL PRIMARY KEY,
  provider      TEXT        NOT NULL,
  delivery_id   TEXT        NOT NULL,
  event_type    TEXT        NOT NULL,
  payload       JSONB       NOT NULL,
  received_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at  TIMESTAMPTZ,
  UNIQUE (provider, delivery_id)
);
// Insert first. A conflict means we have seen it before.
const { rowCount } = await db.query(
  `INSERT INTO webhook_events (provider, delivery_id, event_type, payload)
   VALUES ($1, $2, $3, $4)
   ON CONFLICT (provider, delivery_id) DO NOTHING`,
  ['stripe', deliveryId, eventType, payload],
);

if (rowCount === 0) {
  return res.status(200).json({ status: 'duplicate' });  // still 200
}

Return 200 for duplicates. A non-2xx tells the provider to retry again, which is the opposite of what you want.

Step five: respond fast, work later

Most providers time out webhook deliveries somewhere between five and thirty seconds. If your handler sends an email, generates a PDF, and calls two APIs inline, you will exceed that — and the provider will retry an operation that actually succeeded.

The correct shape is: verify, persist, return 200, process asynchronously.

app.post('/webhooks/stripe', async (req, res) => {
  if (!verify(req.body, req.get('stripe-signature'), SECRET)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Persist and dedupe -- fast, one insert
  const inserted = await recordEvent(event);

  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Real work happens outside the request
  if (inserted) await queue.publish('webhook.process', { id: event.id });
});

The status codes carry meaning. Return 200 for accepted and for duplicates. Return 401 for a bad signature — and do not retry-loop on it. Return 500 only when you genuinely want a retry, such as your database being briefly unavailable. Returning 500 on a payload you cannot parse guarantees the provider will resend it forever.

That architecture needs a durable store and something to consume from it — a managed Postgres and a worker service alongside the web service. The database documentation covers the persistence side, and the events table doubles as your audit log when someone asks why an order was not fulfilled.

Debugging a webhook that is not arriving

  1. Check the provider’s delivery log first. Almost all of them show attempts, status codes, and responses. If there are no attempts, the event never fired and your handler is irrelevant.
  2. Confirm the URL is publicly reachable: curl -X POST https://yourapp.com/webhooks/x -d '{}' from somewhere other than your network.
  3. Check for a redirect. Many providers do not follow them, so an http:// URL that 301s to HTTPS never arrives.
  4. Look for a WAF or bot rule blocking the request. Webhook traffic is unusual enough to trip content-inspection rules, and the origin never sees it.
  5. Verify the path. A trailing-slash mismatch that your router treats as a 404 is a common and invisible cause.

Log every inbound request before any validation. If you only log successful deliveries, a signature-verification bug looks identical to the provider never calling you, and you will spend an afternoon on the wrong half of the problem.

How this fits the rest of the stack

Capture the real payload before writing the handler, tunnel only in development, verify signatures with a constant-time comparison, and treat every delivery as a possible duplicate. Return 200 quickly and do the work behind a queue. If you are sizing a service with a worker and a database for exactly this shape, the RunxBuild hosting calculator shows the web service, worker, and database as separate line items.

Useful related references:

FAQ

How do I see what a webhook is sending?

Point it at a request bin — a throwaway URL that records the full request — or run a small local server that logs headers and raw body, exposed through a tunnel. Do this before writing the handler, since payloads rarely match the documentation exactly.

How do I test a webhook against localhost?

Use a tunnel such as cloudflared or ngrok to give your local server a public URL, or an SSH reverse tunnel if you already have a public server. Once past discovery, most providers’ replay buttons are more convenient.

Why must I use the raw body for signature verification?

The signature is computed over the exact bytes sent. Parsing JSON and re-serialising it changes whitespace and key order, so the recomputed digest will not match even when the request is genuine.

Why am I receiving the same webhook twice?

Webhook delivery is at-least-once. Timeouts, non-2xx responses, and dropped connections all trigger retries. Store the provider’s delivery ID with a unique constraint and return 200 when you see one you already have.

What status code should a webhook handler return?

200 for accepted and for duplicates, 401 for a failed signature check, and 500 only when you actually want a retry. Returning 500 for an unparseable payload makes the provider resend it indefinitely.

#webhook site#webhook testing#request bin#webhook signature#idempotency