> ## Documentation Index
> Fetch the complete documentation index at: https://docs.devhelm.io/llms.txt
> Use this file to discover all available pages before exploring further.

# What Is Webhook Testing?

> What webhook testing proves, how capture differs from polling and tunnels, and why each test needs its own inbox

Webhook testing proves that your system sent the right HTTP request to a receiver: the right event, body, headers, and signature, at the right moment. The test triggers the action, captures what was sent, and asserts on it.

## How it works

A capture endpoint is a URL that accepts any request and stores it. Your test points the code under test at that URL, makes it send, then reads back what arrived:

1. **Configure** the sender to use the capture URL instead of the real receiver.
2. **Trigger** the action that should send a webhook: place an order, change a status, run a job.
3. **Read** the captured request: method, path, query, headers, and raw body.
4. **Assert** on the parts that matter: the event type, the payload, the signature header.

The capture endpoint can also play the receiver's part in the reply. Returning a `500` or a slow response is how you test the sender's retries and timeouts.

## Three things called "webhook"

The word covers traffic in both directions. In the DevHelm docs:

| | Who sends | Who receives | What it proves |
| - | - | - | - |
| **Webhook inbox** (capture) | Your code | A DevHelm inbox | Your system sends the right requests |
| **Uptime check** | DevHelm probes | Your endpoint | Your endpoint is up and answers correctly |
| **Alert webhook** | DevHelm | Your URL | You hear about incidents in your own tools |

An inbox is a testing tool. An [HTTP monitor](/monitoring/http/overview) checks your receiver from outside on a schedule. An [alert webhook](/integrations/webhook) is DevHelm sending events out.

## Getting the request into the test

| | How it works | Good for | Weak at |
| - | - | - | - |
| **Poll** | List captured requests in a loop with a sleep between tries | Ad hoc inspection | Sleeps make tests slow when long and flaky when short |
| **Wait** | One call blocks until a matching request is stored, or fails with a timeout | Assertions in CI | Needs a start time, below |
| **Tunnel** | Forward the request to a process on your machine | Running your own receiver code during development | Needs a live process; leaves no stored record to assert on |

DevHelm inboxes use a server-side wait. It returns the oldest stored request that matches, as soon as it exists, or `408 WAIT_TIMEOUT` when the time runs out. DevHelm does not forward or replay requests to another URL.

### Record the start time first

A wait has to know which requests count. It matches requests received at or after a timestamp you pass. Take that timestamp **before** the trigger:

```typescript theme={null}
const since = new Date().toISOString();
await placeOrder();
const event = await inbox.wait({ receivedAfter: since, timeoutMs: 30_000 });
```

Taken after the trigger, a fast sender has already delivered, the request falls outside the window, and the wait times out. The DevHelm CLI and SDKs default this timestamp to the moment you call wait, so pass it explicitly.

Waiting does not consume anything. The same call returns the same request again, which keeps retries of a test step safe.

### Proving nothing was sent

"No webhook is sent for a failed payment" is a wait that is expected to time out. Its timeout has to cover the sender's normal latency plus the few seconds the inbox takes to store a request. Too short, and the test passes before a late request lands.

## One inbox per test

A shared capture URL is the most common source of wrong matches: two tests, or two CI jobs, send to the same inbox, and one reads the other's request. Isolate by test:

* **One inbox per test** is the simplest. Create it at the start, delete it at the end.
* **One inbox per CI job, one path per test** fits a plan's inbox cap. The inbox accepts any path below its token, so each test sends to `/<run-id>/<test-id>` and waits with that path as a prefix.

Inbox slots are counted per organization, and all waits in an organization share one rate limit. [Testing in CI](/guides/testing-in-ci) shows a layout that fits both.

## Signatures

Most providers sign each webhook so the receiver can tell it is genuine. Stripe sends `Stripe-Signature`, GitHub sends `X-Hub-Signature-256`, and the Standard Webhooks spec uses `webhook-signature`. Each is an HMAC over the exact bytes of the body.

That makes signature tests fragile in one specific way: any step that parses and re-serializes the body changes whitespace or key order, and the HMAC no longer matches. Verify against the raw bytes, never against a parsed or decoded copy.

A DevHelm inbox stores the raw body byte for byte, with its SHA-256, and keeps every header. Header names arrive lowercased, so look up `stripe-signature`, not `Stripe-Signature`. There is no built-in verify call; you run the same check your receiver runs. [Verify webhook signatures](/testing/webhooks/verify-signatures) has the code for each scheme.

## Testing failure handling

Receivers fail, and senders are supposed to retry. To test that, the capture endpoint has to fail on purpose: return a `500`, a `429`, or a slow reply, then let the sender retry and check the attempts it made. A DevHelm inbox has a configurable reply (status, body, headers, and a delay of up to 30 seconds) for this. See [Simulate a failing receiver](/testing/webhooks/simulate-failures).

## DevHelm webhook inboxes

<CardGroup cols={2}>
  <Card title="Your first webhook inbox" icon="rocket" href="/guides/first-webhook-inbox">
    Create an inbox, send it a request, and assert on it in a test.
  </Card>

  <Card title="Webhook inboxes" icon="inbox" href="/testing/webhooks/overview">
    What an inbox stores, mock replies, and every way to read events.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.