# WebhookTool HTTP API

WebhookTool gives you a URL that records every HTTP request sent to it, and every DNS lookup of a name under it, and every email sent to an address of its own. Everything the dashboard does is also available over this API, for scripts, test suites and AI agents. The base URL is `https://webhooktool.com`, and request and response bodies are JSON unless noted.

## Quick start

Create an inbox. The response holds its `id`, its webhook URL in `urls.ingest` and its `secret`, which is returned only this once.

```sh
curl -s -X POST https://webhooktool.com/api/v1/inboxes
```

Send it anything.

```sh
curl -X POST 'https://whks.dev/<id>/orders?source=test' \
  -H 'Content-Type: application/json' \
  -d '{"hello": "world"}'
```

Read what arrived.

```sh
curl -s -H 'Authorization: Bearer <secret>' https://webhooktool.com/api/v1/inbox/captures
```

Open `https://webhooktool.com/#<secret>` in a browser to see the same inbox in the dashboard.

## MCP for AI agents

AI agents can use WebhookTool through its MCP server at `https://webhooktool.com/mcp`. It needs no account or sign-in, because the agent creates its own inboxes and passes each inbox's secret to the tools that read or change it. To let an agent use an inbox you already have, give it the inbox's private link.

Add it to Claude Code.

```sh
claude mcp add --transport http webhooktool https://webhooktool.com/mcp
```

Or to Codex.

```sh
codex mcp add webhooktool --url https://webhooktool.com/mcp
```

In VS Code, add `"webhooktool": {"type": "http", "url": "https://webhooktool.com/mcp"}` under `servers` in `mcp.json`. In Cursor, add `"webhooktool": {"url": "https://webhooktool.com/mcp"}` under `mcpServers` in `mcp.json`. Other agents take it as a remote MCP server with this URL, over Streamable HTTP.

| Tool | Does |
|---|---|
| `create_inbox` | Create an inbox, with any [settings](#settings). Returns its URLs, dashboard link and secret. |
| `get_inbox` | Get the inbox. |
| `update_inbox` | Change its settings. |
| `delete_inbox` | Destroy it and everything in it. |
| `wait_for_capture` | Wait for the next capture, or the next one matching a [search](#search), and return it. |
| `list_captures` | List or search captures, without their headers and bodies. |
| `get_capture` | Get one capture with its headers and body. Long bodies come in parts. |
| `get_capture_as` | Get a request as code that sends it again. |
| `check_signature` | Check an Amazon SNS or PayPal signature with the sender's certificate, a Google Pub/Sub, Microsoft Graph or Azure Event Grid token with its issuer's published keys, an App Store notification with Apple's root certificate, or a decoded JWT from Google, Microsoft or Apple. |
| `set_capture_note` | Set or remove a note. |
| `delete_captures` | Delete captures by ID, by search, or all of them. |

Anyone who knows an inbox's URL can send to it, so the server tells agents to treat captures as data and not to follow instructions in them.

## Inboxes and secrets

- An inbox has an `id`, a UUID that is part of its webhook URL `https://whks.dev/<id>`. Anyone who knows the webhook URL can send requests to it, but reading them needs the secret. To operate and maintain the service, the operators may also access inbox data, as the [privacy page](https://webhooktool.com/privacy) explains.
- The `secret`, 26 lowercase letters and digits, reads, changes and deletes the inbox. Send it as a bearer token: `Authorization: Bearer <secret>`. It is returned only when the inbox is created and cannot be recovered. The link `https://webhooktool.com/#<secret>` opens the inbox in the dashboard, and `https://webhooktool.com/#<secret>/<capture-id>` opens one capture.
- An inbox and everything in it are deleted `retention_seconds` after the inbox was created: 1 day unless you choose between 5 minutes and 3 days. `expires_at` says when. You can change the time later, but not to less than the inbox's current age.
- An inbox keeps its newest `capture_limit` captures: 50 unless you choose 1 to 100, and at most 10.0 MiB in total. Older captures are deleted to make room.
- How many inboxes you can have at a time is limited. Destroying an inbox makes room right away.
- How many inboxes you can create within 1 day is limited too, and destroyed and expired inboxes count toward it. Once you reach it, you can create the next inbox 1 day after your latest one.

## Sending requests

Any method, path, query string, headers and body sent to `https://whks.dev/<id>` or a path below it are recorded. Bodies are recorded up to 1.0 MiB. Anything beyond that is cut off and the capture is marked `"truncated": true`.

The inbox answers with the response in its [settings](#settings), plus an `X-Request-Id` header holding the capture's `id` and `X-Robots-Tag: noindex, nofollow`, which keeps inbox URLs out of search engines. To answer one request with another status, start the path with a status code from 200 to 599, as in `https://whks.dev/<id>/404/anything`.

Browsers that load the webhook URL themselves, as a page, a frame, a script, an image or the like, get the body as `text/plain; charset=utf-8` whatever `content_type` says, so an inbox can't show visitors a web page, start a download or run as a script. Calls from a page's scripts with `fetch()` or `XMLHttpRequest`, and requests from anything that isn't a browser, such as webhook senders and `curl`, get the response exactly as set.

With `cors` on, responses allow every origin, and CORS preflight requests (`OPTIONS` with `Origin` and `Access-Control-Request-Method` headers) are answered with `204` and allow the requested method and headers.

Other answers from the webhook URL are plain text.

| Status | Meaning | Recorded |
|---|---|---|
| `404` | There is no inbox with that `id`, or it expired. | No |
| `403` | The request asked for a service worker script, or one of WebhookTool's own pages asked for a script, worker or style sheet. Inboxes do not serve those. | No |
| `429` | Requests are reaching the inbox too fast. Retry after `Retry-After` seconds. | No |
| `429` | Too many responses with a delay are waiting to be sent for the inbox. | Yes |
| `503` | The server is busy. Retry after `Retry-After` seconds. | No |
| `507` | The server's storage is full, and no inbox had older requests to give up. When storage is full, the inboxes of the network that stores the most lose their oldest captures first to make room. | No |

## DNS lookups

Lookups of any name under `<id>.1.whks.dev` are recorded in the inbox and answered with no records. The inbox's `urls.dns` holds that pattern. Lookups count toward the same rate limit as requests, and lookups above it are answered but not recorded.

```sh
dig hello.<id>.1.whks.dev
```

A lookup is a [capture](#capture) with `"kind": "dns"`, `"method": "DNS"` and a `dns` object. Its `remote_ip` is the address of the resolver that sent the lookup, usually not that of the system that made it. When the resolver's address lies in a network's published ranges, `insights.network` names that network, such as `google-public-dns` for Google Public DNS.

## Emails

Emails to any address at `<id>.email.whks.dev`, such as `hello@<id>.email.whks.dev`, are recorded in the inbox. The inbox's `urls.email` holds that pattern. Messages up to 1.0 MiB are recorded, and longer ones are cut off. Mail for an unknown inbox is refused. Emails count toward the same rate limit as requests, and those above it are refused for the time being, so mail servers send them again later.

An email is a [capture](#capture) with `"kind": "email"`, `"method": "SMTP"` and an `email` object. Its `proto` is `SMTP`, `ESMTP`, or `ESMTPS` after STARTTLS, or `UTF8SMTP` and `UTF8SMTPS` when the sender used SMTPUTF8, and its `remote_ip` is the address of the sending mail server. `headers` lists the message's header fields in the order they came, `files` its attachments and other parts by the part's `index`, and `body` is the raw message, downloaded as `message/rfc822`. Getting one capture also returns `text` and `html`, the message's plain text and HTML, decoded.

Each email is checked with SPF, DKIM and DMARC when it arrives, and `email.auth` holds the results: whether the sending server may send mail for the envelope sender's domain, whether each DKIM signature verifies, and whether the message passes the DMARC policy of its From address's domain. The checks use the DNS records the sender's domains had then. Only DMARC ties the From address to its domain's owner.

## Endpoints

Every endpoint except `POST /api/v1/inboxes` needs the inbox's secret as a bearer token, and acts on that inbox. Browsers can only call endpoints that change something from WebhookTool's own pages. Such requests from other sites are refused with `403`. Scripts and other programs that are not browsers are not affected.

| Endpoint | Does |
|---|---|
| `POST /api/v1/inboxes` | Create an inbox. |
| `GET /api/v1/inbox` | Get the inbox. |
| `PATCH /api/v1/inbox` | Change its settings. |
| `DELETE /api/v1/inbox` | Destroy it and everything in it. |
| `GET /api/v1/inbox/captures` | List or search captures. |
| `DELETE /api/v1/inbox/captures` | Delete all captures, or those matching a search. |
| `GET /api/v1/inbox/captures/{id}` | Get one capture with its body. |
| `DELETE /api/v1/inbox/captures/{id}` | Delete one capture. |
| `GET /api/v1/inbox/captures/{id}/body` | Download the raw body. |
| `GET /api/v1/inbox/captures/{id}/files/{n}` | Download an uploaded file. |
| `GET /api/v1/inbox/captures/{id}/as/{format}` | Get the request as code that sends it again. |
| `PUT /api/v1/inbox/captures/{id}/note` | Set or remove a note. |
| `POST /api/v1/inbox/captures/{id}/signature` | Check a signature with the sender's certificate or its issuer's published keys. |
| `GET /api/v1/inbox/export` | Download captures as JSON, CSV or HAR. |

### POST /api/v1/inboxes

Creates an inbox and answers `201 Created` with the [inbox](#inbox), including its `secret`. The body is optional and may hold any [settings](#settings) to start with.

```sh
curl -s -X POST https://webhooktool.com/api/v1/inboxes \
  -H 'Content-Type: application/json' \
  -d '{"retention_seconds": 86400, "response": {"status": 202, "body": "accepted"}}'
```

### GET /api/v1/inbox

Returns the [inbox](#inbox).

### PATCH /api/v1/inbox

Changes any of the inbox's [settings](#settings) and returns the [inbox](#inbox). Fields you leave out keep their values, also inside `response`.

```sh
curl -s -X PATCH https://webhooktool.com/api/v1/inbox \
  -H 'Authorization: Bearer <secret>' \
  -H 'Content-Type: application/json' \
  -d '{"capture_limit": 100, "response": {"cors": true}}'
```

### DELETE /api/v1/inbox

Destroys the inbox, so it and its captures are deleted right away and its secret stops working. Answers `204 No Content`.

### GET /api/v1/inbox/captures

Lists [captures](#capture), without their bodies.

| Parameter | Default | Meaning |
|---|---|---|
| `page` | `1` | Page number, from 1. |
| `per_page` | `50` | Captures per page, from 1 to 100. |
| `sort` | `newest` | `newest` or `oldest` first. |
| `q` | | Only captures matching this [search](#search). |

```json
{
  "data": [
    {"id": "01a10371-afd5-7ba1-8019-b929b9af644a", "received_at": "2026-10-03T20:31:02.517Z", "kind": "http", "method": "POST", "url": "https://whks.dev/6ba141bf-2c4f-4601-a998-b0b878661de7/orders?source=test", "status": 200}
  ],
  "total": 1,
  "page": 1,
  "per_page": 50,
  "sort": "newest"
}
```

The captures in `data` have every field described under [Capture](#capture) except `body`. A search stops reading bodies after a set amount of data, and its result then has `"partial": true`.

### DELETE /api/v1/inbox/captures

Deletes every capture, or with `q` only those matching a [search](#search). Answers `{"deleted": 3, "partial": false}`, where `partial` is `true` when the search stopped early, as when listing.

### GET /api/v1/inbox/captures/{id}

Returns one [capture](#capture), including its `body`.

### DELETE /api/v1/inbox/captures/{id}

Deletes one capture. Answers `204 No Content`.

### GET /api/v1/inbox/captures/{id}/body

Downloads the raw body as `application/octet-stream`, or an email's raw message as `message/rfc822` in an `.eml` file.

### GET /api/v1/inbox/captures/{id}/files/{n}

Downloads the uploaded file whose `index` is `n` in the capture's `files`, or an email's part with that index, decoded.

### GET /api/v1/inbox/captures/{id}/as/{format}

Returns the request as text that sends it again. `format` is `curl`, `httpie`, `fetch` (JavaScript), `python` (requests), `powershell`, or `har` for a HAR 1.2 document. The copy is addressed to the inbox's webhook URL with the same path and query, not to the host named in the original request, which the sender chose. Not available for DNS lookups or emails.

### PUT /api/v1/inbox/captures/{id}/note

Sets the capture's note, up to 10000 characters, and returns the capture without its body. An empty note removes it.

```sh
curl -s -X PUT https://webhooktool.com/api/v1/inbox/captures/<capture-id>/note \
  -H 'Authorization: Bearer <secret>' \
  -H 'Content-Type: application/json' \
  -d '{"note": "retried by the provider"}'
```

### POST /api/v1/inbox/captures/{id}/signature

Checks the signature of an Amazon SNS or PayPal capture with the signing certificate it names. The server downloads that certificate from Amazon's or PayPal's own servers, never from anywhere else, and keeps it for 12 hours. A PayPal signature also needs `webhook_id`, the ID PayPal gave the webhook. Signatures made with a shared secret, such as GitHub's or Stripe's, are checked in the dashboard instead, which keeps the secret in the browser. So are SendGrid's Signed Event Webhook (`sendgrid`, ECDSA P-256 over the timestamp and the body) and Discord's interactions (`discord`, Ed25519 over the timestamp and the body), with the public key SendGrid or Discord shows you. Discord's needs a browser that supports Ed25519 in WebCrypto.

Some senders sign without naming a certificate, and these are checked with keys their issuer publishes.

- Google Pub/Sub push requests (`google-oidc`) carry a token from Google in `Authorization`, checked with Google's keys from `www.googleapis.com`. Its issuer must be `accounts.google.com`.
- Microsoft Graph change notifications with resource data (`microsoft-graph`) carry tokens in `validationTokens`, and Azure Event Grid with Microsoft Entra authentication (`azure-entra`) carries one in `Authorization`. Both are checked with Microsoft identity's keys from `login.microsoftonline.com`, and their issuer must be Microsoft identity for a tenant. Each Graph token must also be issued to Microsoft Graph Change Tracking, the app `0bf30f3b-4a52-48df-9a82-234910c4a086`.
- App Store Server Notifications V2 (`apple`) carry the certificates that signed `signedPayload`. They must lead to Apple Root CA - G3, which WebhookTool carries, and carry the marks Apple puts on its signing certificates, so nothing is downloaded.

Tokens and certificates must have been valid when the request arrived, allowing 5 minutes of clock difference for tokens. A token shows who sent the request, but doesn't cover its body. The result's `token` holds what the token says, such as its `issuer`, `subject`, `audience`, the service account's `email` and whether Google marked it `email_verified`, the `app` that requested it, its `tenant`, `issued_at`, `expires_at`, and the `key_id` and `keys_url` of the key that checked it. For Apple, `certificate` is the certificate that signed the payload, and `token` holds the `app` bundle ID and the `environment`.

To check a JWT that `decoded` lists instead, send its `from` as `layer`, as in `-d '{"layer": "body.id_token"}'`. Only tokens that Google (`accounts.google.com`), Microsoft identity or Sign in with Apple (`https://appleid.apple.com`) issued with RS256 can be checked, with keys downloaded only from those issuers' fixed addresses. Any other token gets `400` with `not_checkable`.

```sh
curl -s -X POST https://webhooktool.com/api/v1/inbox/captures/<capture-id>/signature \
  -H 'Authorization: Bearer <secret>'
```

```json
{"scheme": "aws-sns", "valid": true, "certificate": {"url": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-<id>.pem", "subject": "CN=sns.amazonaws.com", "issuer": "CN=Amazon RSA 2048 M01,O=Amazon,C=US", "not_after": "2027-06-21T23:59:59Z"}}
```

For PayPal, send the webhook ID as JSON, as in `-H 'Content-Type: application/json' -d '{"webhook_id": "1JE4291016473214C"}'`.

```json
{"scheme": "google-oidc", "valid": true, "token": {"issuer": "https://accounts.google.com", "subject": "112233445566778899000", "audience": ["https://example.com/push"], "email": "pusher@my-project.iam.gserviceaccount.com", "email_verified": true, "key_id": "<key-id>", "keys_url": "https://www.googleapis.com/oauth2/v3/certs", "issued_at": "2026-10-12T10:00:00Z", "expires_at": "2026-10-12T11:00:00Z"}}
```

`valid` is `false` with a `reason` when the signature fails. `mismatch` means it doesn't match the certificate or key, `certificate_url` that the certificate it names isn't on the provider's own servers, so the provider didn't send it, `certificate_unknown` that it is on the provider's own domain but not where WebhookTool expects certificates, so it wasn't checked, `certificate_expired` that the certificate had expired when the request arrived, `certificate_chain` that Apple's certificates don't lead to Apple's root or lack Apple's marks, and `unsupported` that it uses a version that can't be checked. For tokens, `token_expired` means the token had expired or wasn't valid yet when the request arrived, `key_unknown` that its issuer doesn't publish the key that signed it, which also happens once an issuer drops an old key, `issuer` that the sender doesn't issue tokens like it, and `token_app` that a Microsoft Graph token wasn't issued to Microsoft Graph Change Tracking. A capture without such a signature, or whose body was cut off, gets `400` with `not_checkable`.

### GET /api/v1/inbox/export

Downloads captures with their bodies, oldest first, as a file. `format` is `json` (an array of [captures](#capture), the default), `csv` (one row per capture, with lists and objects as JSON, and a leading `'` on text starting with `=`, `+`, `-` or `@` so spreadsheets do not run it as a formula) or `har` (HAR 1.2, without DNS lookups and emails). With `q`, only captures matching a [search](#search) are included.

```sh
curl -s -OJ -H 'Authorization: Bearer <secret>' 'https://webhooktool.com/api/v1/inbox/export?format=har'
```

## Objects

### Inbox

```json
{
  "id": "6ba141bf-2c4f-4601-a998-b0b878661de7",
  "secret": "k7n2xq4vb6mz3wd5rf2hp7ty4c",
  "urls": {
    "ingest": "https://whks.dev/6ba141bf-2c4f-4601-a998-b0b878661de7",
    "dns": "*.6ba141bf-2c4f-4601-a998-b0b878661de7.1.whks.dev",
    "email": "*@6ba141bf-2c4f-4601-a998-b0b878661de7.email.whks.dev"
  },
  "created_at": "2026-10-03T20:25:36.123456Z",
  "expires_at": "2026-10-04T00:25:36.123456Z",
  "captures": 0,
  "settings": {
    "response": {
      "status": 200,
      "content_type": "text/plain; charset=utf-8",
      "body": "",
      "headers": [],
      "delay_ms": 0,
      "cors": false
    },
    "retention_seconds": 86400,
    "capture_limit": 50,
    "ip_mode": "full"
  }
}
```

| Field | Meaning |
|---|---|
| `id` | The inbox ID, part of its webhook URL. |
| `secret` | The bearer token. Only in the answer that creates the inbox. |
| `urls.ingest` | The webhook URL. |
| `urls.dns` | The names whose lookups are recorded. |
| `urls.email` | The addresses whose emails are recorded. |
| `created_at` | When the inbox was created, in UTC. |
| `expires_at` | When the inbox and its captures will be deleted, in UTC. |
| `captures` | How many captures the inbox holds. |
| `settings` | The inbox's [settings](#settings). |
| `restriction` | Only while the operator restricts the inbox, which happens to inboxes that break the terms of use. `http` is `true` when new requests are refused with `403`, `dns` when lookups are not recorded, `email` when new emails are refused, and `response` when requests get the default answer instead of the inbox's. `notice` says why, and `expires_at` when the restriction ends, if it does. |

### Settings

| Field | Default | Allowed values |
|---|---|---|
| `response.status` | `200` | 200 to 599. |
| `response.content_type` | `text/plain; charset=utf-8` | A header value of up to 256 bytes. Empty sends no `Content-Type`. Browsers that open the URL directly get `text/plain` instead. |
| `response.body` | empty | Up to 16.0 KiB of text. A refresh `<meta>` tag is refused, since redirects are disabled to prevent abuse. |
| `response.headers` | `[]` | Up to 32 extra headers as `{"name": "X-Signature", "value": "abc123"}`, each value up to 4096 bytes, and names and values together up to 8.0 KiB. Headers such as `Content-Type`, `Content-Length`, `Content-Disposition`, `Set-Cookie`, `Content-Security-Policy`, `WWW-Authenticate` and `X-Robots-Tag` cannot be set, and neither can CORS headers such as `Access-Control-Allow-Origin`, which `cors` sends. `Location` and `Refresh` are refused too, since redirects are disabled to prevent abuse. |
| `response.delay_ms` | `0` | 0 to 30000. Waits this long before answering. |
| `response.cors` | `false` | `true` lets web pages on other sites call the webhook URL. |
| `retention_seconds` | `86400` | 300 to 259200, counted from the inbox's creation. Not less than its current age. |
| `capture_limit` | `50` | 1 to 100. |
| `ip_mode` | `full` | How much of each sender's IP address the inbox shows, either `full`, `truncate` (the /24 or /48 network) or `none`, which also leaves out the `network` the address lies in. The server keeps the whole address and its network either way, so a change applies to earlier captures too. |

### Capture

```json
{
  "id": "01a10371-afd5-7ba1-8019-b929b9af644a",
  "received_at": "2026-10-03T20:31:02.517Z",
  "kind": "http",
  "method": "POST",
  "url": "https://whks.dev/6ba141bf-2c4f-4601-a998-b0b878661de7/orders?source=test",
  "host": "whks.dev",
  "path": "/orders",
  "query": [{"name": "source", "value": "test"}],
  "headers": [
    {"name": "Accept", "value": "*/*"},
    {"name": "Content-Length", "value": "18"},
    {"name": "Content-Type", "value": "application/json"},
    {"name": "Host", "value": "whks.dev"},
    {"name": "User-Agent", "value": "curl/8.7.1"}
  ],
  "proto": "HTTP/1.1",
  "remote_ip": "203.0.113.7",
  "content_type": "application/json",
  "content_length": 18,
  "body_size": 18,
  "status": 200,
  "body": "{\"hello\": \"world\"}",
  "body_encoding": "utf-8"
}
```

| Field | Meaning |
|---|---|
| `id` | The capture ID, a UUIDv7. |
| `received_at` | When the request arrived, in UTC. |
| `kind` | `http`, `dns` or `email`. |
| `method`, `url`, `host`, `proto` | As received. |
| `path` | The path after the inbox ID, as sent. |
| `query`, `form` | Query string and form fields as lists of `name` and `value`, in the order sent. |
| `headers` | Request headers as a list of `name` and `value`, sorted by name. |
| `remote_ip` | The sender's IP address, or the resolver's for a DNS lookup, or the sending mail server's for an email, as far as `ip_mode` shows it. Left out with `none`. |
| `tls` | `version`, `cipher_suite`, `server_name` and `alpn` of an HTTPS connection. |
| `timing` | How long the request took to arrive, in milliseconds to the microsecond. `tls_handshake_ms` is the TLS handshake of an HTTPS connection, given for its first request only, and `reused_connection` is `true` for the requests after it. `headers_ms` runs from the request's first byte to the end of its headers, over HTTP/1 only. `body_ms` is how long reading the body took. Behind a reverse proxy they time the proxy's connection. Left out when nothing is known. |
| `content_type`, `content_length` | The request's `Content-Type` and `Content-Length`, with `-1` when the length was not given. |
| `body_size` | Bytes of body recorded. |
| `truncated` | `true` when the body was longer than 1.0 MiB and cut off. |
| `form_truncated` | `true` when only the first fields or files of a large form are listed. The full form is in the body. |
| `files` | Uploaded files as `index`, `field`, `filename`, `content_type`, `size` and `detected_type`, the type the start of the file's content shows, when it shows one. |
| `status` | The status the webhook URL answered with. |
| `note` | Your note, if any. |
| `insights` | What a request names about itself, worked out when it arrived. `source` is the sender, such as `github` or `stripe`, `event` the event type, `delivery` the ID that retries of one delivery share, `attempt` the attempt number when the sender counts them and `occurred_at` when the event happened. `signature` names the `scheme`, `header`, `algorithm` and `encoding` of the request's signature, the content it signs such as `{timestamp}.{body}`, and `signed_at`. `handshake` marks a request sent to verify the URL, and for DNS lookups `decoded` lists the parts of the name that decode to text, each with `from`, `encoding` and `text`. `ip_list` tells whether the sender's address is on a list of addresses a provider publishes, with that `list` and `listed`. For a sender that has one, it is that sender's list, such as `github`, `stripe` or `atlassian` for their webhooks, `aws` for Amazon SNS and Data Firehose, `google` for Google Pub/Sub, or `azure` for Azure Event Grid and Microsoft Graph. Otherwise it is a webhook list that holds the address. `network` names the cloud, hosting provider, service, crawler, uptime monitor, VPN or anonymizer whose published ranges, or for providers such as `ovhcloud` or `leaseweb` the blocks it registered at the internet registries, hold the address, with its `list`, such as `aws`, `google-cloud`, `azure`, `github-actions`, `hetzner`, `digitalocean`, `google-crawlers`, `bingbot`, `applebot`, `openai`, `uptimerobot`, `pingdom`, `nordvpn`, `proton-vpn`, `mullvad`, `pia`, `ivpn`, `ovpn`, `google-vpn`, `cloudflare-warp`, `tor` or `icloud-private-relay`. A VPN's list names the addresses its servers take connections on, which is where most VPNs send their users' traffic from, or the ranges it sends from. A list of the addresses a provider uses for one service, such as its crawlers, names an address before the provider's whole network. It has the `region` when the provider names one, either a region such as `eu-north-1` or a place such as `Falkenstein, DE`. For DNS lookups it names the resolver's network, such as `google-public-dns`. It is left out when `ip_mode` is `none`, and so are the network's mark and matches for `network:` and `has:network`. Both need the server to have the lists. The sender chose everything else these come from, so a matching signature is what shows who sent it. |
| `insights.agent` | The crawler, link preview, AI fetcher, uptime monitor, scanner or HTTP client the `User-Agent` names, with its `id`, such as `googlebot`, `slack` or `curl`, its `kind`, one of `crawler`, `preview`, `ai`, `monitor`, `scanner` or `client`, and the `version` of an HTTP client when it gives one. The dashboard shows an agent as verified when the sender's address is in the ranges its owner publishes for it, which `insights.network` names, such as `google-crawlers`, `bingbot`, `applebot`, `openai`, `uptimerobot` or `pingdom`. |
| `body`, `body_encoding` | The body, with `body_encoding` `utf-8`, or `base64` when it is not valid UTF-8. Only when getting one capture and in JSON exports. |
| `decoded` | What the body, form fields, query and headers hold encoded, decoded, such as a gzip body, JSON inside strings, base64 data, JWTs and Basic credentials. Each has `from`, `encoding`, `format` (`json`, `text`, `jwt` or `basic`), `text`, and `user` and `password` for Basic credentials. JWT signatures are not checked here, but a JWT that Google, Microsoft or Apple issued can be checked [on request](#post-apiv1inboxcapturesidsignature). Only when getting one capture. |
| `dns` | For DNS lookups, the `name`, `type`, `class`, `transport` (`udp` or `tcp`), `recursion_desired`, and `edns` with `udp_size` and `dnssec_ok` when the query used EDNS. |
| `email` | For emails, the envelope's `helo`, `mail_from` and `rcpt_to`, the header fields `from`, `to`, `cc`, `reply_to`, `subject`, `date` and `message_id` with encoded words decoded, and `text` and `html` with the `index`, `content_type` and `size` of the parts holding the plain text and HTML. `auth` holds the SPF, DKIM and DMARC results, with their names from RFC 8601. `auth.spf` has the `result` (`none`, `neutral`, `pass`, `fail`, `softfail`, `temperror` or `permerror`), the `domain` checked, the `identity` it came from (`mailfrom`, or `helo` when the envelope sender is empty), the record term that `matched`, the `record` holding it and a `reason`. With an `ip_mode` other than `full`, `matched` names only the term's mechanism, such as `ip4`. `auth.dkim` lists one object per signature, with the `result` (`pass`, `fail`, `neutral`, `policy`, `temperror` or `permerror`), the signing `domain` (`d=`), `selector` (`s=`), `algorithm`, `identity` (`i=`), `length` when the signature covers only that many bytes of the body, the RSA `key_bits`, `testing` when the key is marked as in testing, and a `reason`. It is left out for an unsigned message. `auth.dmarc` has the `result` (`none`, `pass`, `fail`, `temperror` or `permerror`), the From address's `domain`, the `policy_domain` whose record applies, its `policy` (`none`, `quarantine` or `reject`), `testing`, `spf_aligned` and `dkim_aligned` when SPF or a DKIM signature passed for a domain aligned with the From domain, and a `reason`. When the checks did not run, `auth` holds only a `reason`. |
| `text`, `html` | An email's plain text and HTML, decoded. Only when getting one capture. |

`query`, `form`, `form_truncated`, `files`, `tls`, `remote_ip`, `truncated`, `note`, `insights`, `decoded`, `dns`, `email`, `text` and `html` are left out when they are empty or do not apply.

## Search

Use `q` to search, for example `method:post status:4xx -path:/health`.

```sh
curl -s -G -H 'Authorization: Bearer <secret>' https://webhooktool.com/api/v1/inbox/captures \
  --data-urlencode 'q=method:post status:4xx -path:/health'
```

Plain words match anywhere in a capture's method, URL, sender address, note, header, query and form names and values, file names, named sender and event, and body. All matching ignores case. Terms are combined with AND. Use `OR`, `NOT` or a leading `-`, parentheses, and double quotes around values with spaces. A search can be up to 1000 characters long with up to 32 terms. It stops reading bodies after a set amount of data and then reports `"partial": true`.

| Term | Matches |
|---|---|
| `method:post` | The HTTP method. |
| `path:/orders` `url:example` | Text in the path after the inbox ID, or in the full URL. |
| `ip:203.0.113` | Text in the sender's address. `from:` works too, and also matches an email's sender address. |
| `body:"order.created"` | Text in the body. |
| `header:stripe` `header.user-agent:stripe` | Text in any header, or in one header by name. `query`, `query.name`, `form` and `form.name` work the same way. |
| `file:invoice` | An uploaded file name or field. |
| `note:refund` | Text in your note. |
| `status:404` `status:4xx` `status:>=500` | The status the webhook URL answered with. |
| `size:>10kb` | Body size, with `<`, `<=`, `>`, `>=` and `b`, `kb` or `mb`. |
| `after:now-2h` `before:2026-10-01` | Arrival time, as a date, a date and time such as `2026-10-01T14:30` (UTC), or relative to now with `s`, `m`, `h`, `d` or `w`. |
| `source:stripe` `event:invoice.paid` | The sender and event a request names, such as GitHub's `push` or Stripe's `invoice.paid`. |
| `delivery:evt_123` | The delivery ID that retries of one delivery share. |
| `network:aws` `network:falkenstein` `network:vpn` | Requests from the published or registered addresses of a cloud, hosting provider, service, crawler, uptime monitor, VPN or anonymizer, by its name, such as `aws`, `azure`, `hetzner`, `ovhcloud`, `github-actions`, `google-public-dns`, `google-crawlers`, `openai`, `uptimerobot`, `nordvpn` or `tor`, or by the region or place. `network:vpn` finds every VPN. Inboxes whose `ip_mode` is `none` match none. |
| `agent:googlebot` `agent:preview` `has:agent` `has:client` | The agent the `User-Agent` names, by its id, name or kind. `has:agent` finds crawlers, link previews, AI fetchers, monitors and scanners, and `has:client` HTTP clients such as curl or Python Requests. |
| `json.data.object.status:paid` `json.amount:>1000` | A field of a JSON body by its path, with numbers compared like `size`. A number in the path picks an array item, such as `json.items.0.sku`, and without one any item matches. |
| `has:body` `has:files` `has:header.x-signature` | Captures that have a body, files, a form, a query, a note, a given header, query or form field, or a JSON field such as `has:json.data.id`. `has:signature`, `has:retry`, `has:handshake`, `has:listed` and `has:network` find signed requests, retries their sender marks, requests that verify the URL, requests from an address on a provider's published list and requests from a cloud's or network's published addresses. `has:secret` finds requests whose headers, query, form fields or body seem to hold a secret, such as a GitHub token, a live Stripe key, an AWS access key ID, a Slack token, a Google API key, a private key or a payment card number. |
| `method:dns` `type:txt` `name:hello` | DNS lookups, by record type or by the name looked up. |
| `method:smtp` `subject:invoice` `from:alice@` `to:hello@` | Emails, by text in the subject, the sender's address or IP, or a recipient in `To`, `Cc` or the envelope. `body:` and plain words also search an email's decoded text and HTML. |
| `spf:fail` `dkim:pass` `dmarc:reject` | Emails, by the result of their SPF, DKIM or DMARC check. A result word (`none`, `neutral`, `pass`, `fail`, `softfail`, `temperror`, `permerror`, `policy`) matches exactly, so `spf:fail` leaves out `softfail`, and `dkim:none` finds unsigned emails. Any other value matches text in the domain checked, such as `dkim:example.com`. `dmarc:` also matches the From domain's policy: `none`, `quarantine` or `reject`. |

## Errors

Errors from the API have a JSON body with a machine-readable `code` and a `message`.

```json
{"error": {"code": "invalid", "message": "capture_limit: must be between 1 and 100"}}
```

| Status | `code` | Meaning |
|---|---|---|
| `400` | `invalid` | A setting is not allowed, and `message` names the field. |
| `400` | `invalid_json`, `invalid_body` | The body is not JSON of the expected shape, has unknown fields, or could not be read. |
| `400` | `invalid_parameter`, `invalid_query` | A query parameter or the search is not valid. |
| `400` | `not_checkable` | The capture carries no signature that a provider's certificate or published keys check, or its body was cut off. |
| `401` | `unauthorized` | The secret is missing or wrong, or its inbox expired or was destroyed. |
| `403` | `blocked` | The operator blocked your network, and `message` says why. |
| `404` | `not_found` | No such capture, file, format or endpoint. |
| `410` | `taken_down` | The operator took the inbox down, and `message` says why. |
| `413` | `too_large` | The request body is larger than 1 MiB. |
| `429` | `rate_limited` | Too many requests. Retry after `Retry-After` seconds. |
| `429` | `inbox_limit` | You have the most inboxes allowed at a time, or created the most allowed recently. `Retry-After` counts the seconds until you can create one. |
| `503` | `capacity` | The server holds the most inboxes it allows. |
| `503` | `busy` | The server is busy. Retry after `Retry-After` seconds. |
| `500` | `internal` | Something went wrong on the server. |
| `502` | `certificate_unavailable` | The provider's certificate or keys could not be downloaded. |

## Limits

| What | Limit |
|---|---|
| Inbox lifetime | 1 day, or up to 3 days |
| Captures per inbox | 50, or up to 100 |
| Data per inbox | 10.0 MiB |
| Recorded body size | 1.0 MiB |
| Response body size | 16.0 KiB |
| Extra response headers | 8.0 KiB of names and values |
| Redirects | Disabled to prevent abuse, so `Location` and `Refresh` headers and refresh `<meta>` tags are refused |
