> ## 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.

# Your First Webhook Inbox

> Create a DevHelm webhook inbox, send it a request, wait for the capture, and assert on the body in a test

By the end of this guide, you'll have a webhook inbox that captured a request, and a Vitest or pytest test that waits for that request and asserts on its body.

<Accordion title="Prerequisites">
  * The CLI (`npm install -g devhelm`) or curl, plus `jq`, for the steps; `@devhelm/sdk` with Vitest (ESM only), or `devhelm` with pytest and httpx, for the test
  * A free inbox slot. Inboxes are counted per organization: 3 on the Free testing plan, 10 on Developer

  The testing API needs an API key and a workspace ID. Export the key, look up the workspace, then export its ID:

  ```bash theme={null}
  export DEVHELM_API_TOKEN=dh_live_xxxxxxxx
  curl https://api.devhelm.io/api/v1/workspaces -H "Authorization: Bearer $DEVHELM_API_TOKEN"
  export DEVHELM_WORKSPACE_ID=<workspace-id>
  ```

  The CLI and Python SDK read both variables. The TypeScript SDK reads `DEVHELM_WORKSPACE_ID` and takes the key in its constructor. With curl, send the workspace as a header:

  ```bash theme={null}
  -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
  -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID"
  ```
</Accordion>

## Capture a request

<Steps>
  <Step title="Create an inbox">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm inboxes create --name first-inbox -o json > inbox.json
      INBOX_ID=$(jq -r .id inbox.json)
      INBOX_URL=$(jq -r .httpUrl inbox.json)
      ```

      ```bash API theme={null}
      curl -s -X POST https://api.devhelm.io/api/v1/webhook/inboxes \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
        -H "Content-Type: application/json" \
        -d '{"name": "first-inbox"}' > inbox.json
      INBOX_ID=$(jq -r .data.id inbox.json)
      INBOX_URL=$(jq -r .data.httpUrl inbox.json)
      ```
    </CodeGroup>

    The URL is `https://api.devhelm.io/api/v1/ingest/<public-token>`. It accepts any method and any path below the token, with no auth. The token is the secret: anyone with the URL can send to the inbox.
  </Step>

  <Step title="Record the start time, then send">
    ```bash theme={null}
    SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    curl -i -X POST "$INBOX_URL/orders?source=tutorial" \
      -H "Content-Type: application/json" \
      -d '{"id": "ord_123", "status": "paid"}'
    ```

    Take `SINCE` before anything sends: wait returns the oldest request received at or after it. Without it, the CLI and SDKs use the clock at the moment you call wait, and a request that already arrived is missed.

    The curl stands in for your service. The inbox replies `200` with an empty body plus `X-Event-Id` and `X-Inbox-Id`. Storage is asynchronous, so getting the event by `X-Event-Id` can return 404 for about a second. Wait handles that.
  </Step>

  <Step title="Wait and assert">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm inboxes wait "$INBOX_ID" --received-after "$SINCE" --path-prefix /orders -o json > event.json
      jq -e '.method == "POST" and .path == "/orders" and (.body | fromjson | .status == "paid")' event.json
      ```

      ```bash API theme={null}
      curl -s -X POST "https://api.devhelm.io/api/v1/webhook/inboxes/$INBOX_ID/wait" \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID" \
        -H "Content-Type: application/json" \
        -d "{\"receivedAfter\": \"$SINCE\", \"timeoutMs\": 30000, \"http\": {\"pathPrefix\": \"/orders\"}}" \
        | jq .event > event.json
      jq -e '.method == "POST" and .path == "/orders" and (.body | fromjson | .status == "paid")' event.json
      ```
    </CodeGroup>

    Wait blocks until a matching request is stored, then returns it. A match already stored returns at once. If nothing matches within `timeoutMs` (default 30000), the API returns `408` with code `WAIT_TIMEOUT`. REST returns `{"event": {...}}` with no `data` wrapper; the CLI prints the event object. `jq -e` exits non-zero when the expression is false, so a CI step fails like an assertion.

    The event also carries `query` (repeated keys kept), `headers` (names lowercased, nothing redacted), `sha256` of the raw body, `sourceIp`, and `receivedAt`. Both SDKs parse `body` with `event.json()`. Signature checks hash the raw bytes instead; see [Verify webhook signatures](/testing/webhooks/verify-signatures).
  </Step>

  <Step title="Delete the inbox">
    <CodeGroup>
      ```bash CLI theme={null}
      devhelm inboxes delete "$INBOX_ID" -y
      ```

      ```bash API theme={null}
      curl -s -X DELETE "https://api.devhelm.io/api/v1/webhook/inboxes/$INBOX_ID" \
        -H "Authorization: Bearer $DEVHELM_API_TOKEN" \
        -H "x-phelm-workspace-id: $DEVHELM_WORKSPACE_ID"
      ```
    </CodeGroup>

    Deleting frees the slot and removes the inbox's events. The URL then returns 404.
  </Step>
</Steps>

## Write the test

The same flow as one test file, with cleanup. The `fetch` or `httpx.post` call stands in for the code under test: replace it with the call that makes your service send its webhook.

<CodeGroup>
  ```typescript first-inbox.test.ts theme={null}
  import { Devhelm } from "@devhelm/sdk";
  import { expect, test } from "vitest";

  const client = new Devhelm({ token: process.env.DEVHELM_API_TOKEN! });

  test("captures the order webhook", async () => {
    const inbox = await client.inboxes.create({ name: "first-inbox" });
    try {
      const since = new Date().toISOString();

      await fetch(`${inbox.httpUrl}/orders`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: "ord_123", status: "paid" }),
      });

      const event = await inbox.wait({
        receivedAfter: since,
        timeoutMs: 30_000,
        http: { pathPrefix: "/orders" },
      });

      expect(event.method).toBe("POST");
      expect(event.path).toBe("/orders");
      expect(event.json()).toEqual({ id: "ord_123", status: "paid" });
    } finally {
      await inbox.delete();
    }
  }, 45_000);
  ```

  ```python test_first_inbox.py theme={null}
  from datetime import datetime, timezone

  import httpx
  from devhelm import Devhelm

  client = Devhelm()


  def test_captures_order_webhook():
      inbox = client.inboxes.create(name="first-inbox")
      try:
          since = datetime.now(timezone.utc)

          httpx.post(f"{inbox.http_url}/orders", json={"id": "ord_123", "status": "paid"})

          event = inbox.wait(
              received_after=since,
              timeout_ms=30_000,
              http={"pathPrefix": "/orders"},
          )

          assert event.method == "POST"
          assert event.path == "/orders"
          assert event.json() == {"id": "ord_123", "status": "paid"}
      finally:
          inbox.delete()
  ```
</CodeGroup>

Run it with `npx vitest run first-inbox.test.ts` or `pytest test_first_inbox.py`. One test passes.

The runner timeout (45 s) is longer than the wait timeout (30 s), so a missing request fails with the API's `408` instead of the runner killing the test. The test owns its inbox, so no earlier request can match. In CI, use one inbox per job and a path per test; see [Testing in CI](/guides/testing-in-ci).

## Troubleshooting

<AccordionGroup>
  <Accordion title="The wait returns 408 WAIT_TIMEOUT">
    * `since` was taken after the send, or not passed. Take it before the trigger.
    * The request path does not start with the `pathPrefix` you passed. The match is case-sensitive.
    * The sender failed. Check the status it got back.
  </Accordion>

  <Accordion title="404 Workspace doesn't exist, or 400 WORKSPACE_REQUIRED">
    The CLI and SDKs fall back to workspace `1` when `DEVHELM_WORKSPACE_ID` is unset, which gives the 404. A curl call without the `x-phelm-workspace-id` header gets the 400. Set both from the setup step.
  </Accordion>

  <Accordion title="403 INBOUND_WEBHOOK_INBOX_LIMIT_REACHED">
    Your organization has used all its inbox slots. Find unused ones with `devhelm inboxes list` and delete them. See [Limits, quotas, and errors](/testing/limits).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={3}>
  <Card title="Webhook inboxes" icon="inbox" href="/testing/webhooks/overview">
    Stored fields, mock replies, listing, and raw downloads.
  </Card>

  <Card title="Wait for a webhook in a test" icon="hourglass-half" href="/testing/webhooks/assert-in-tests">
    The Nth request, negative assertions, and per-test paths.
  </Card>

  <Card title="What is webhook testing?" icon="book-open" href="/learn/testing/what-is-webhook-testing">
    Capture vs polling vs tunnels, and why one inbox per test.
  </Card>
</CardGroup>


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