Email Inboxes API
Create test inboxes, read the emails they receive, and wait for the next email from CI (Playwright, Cypress, plain scripts) without opening the dashboard. Every inbox has a public address at @hookinbox.com; your real email provider delivers to it. For a step-by-step guide, see Test emails in CI.
All routes use the usual authentication: an API key as Authorization: Bearer hklst_… or X-API-Key: hklst_…. Inboxes and emails that belong to another organization return 404.
| Route | Result |
|---|---|
GET /api/v1/inboxes | List inboxes |
POST /api/v1/inboxes | Create an inbox |
GET /api/v1/inboxes/:id | Get one inbox |
PATCH /api/v1/inboxes/:id | Rename an inbox |
DELETE /api/v1/inboxes/:id | Delete an inbox and its emails |
GET /api/v1/inboxes/:id/emails | List emails, newest first, with filters |
GET /api/v1/inboxes/:id/emails/wait | Wait for the next matching email |
GET /api/v1/inboxes/:id/emails/:email_id | Get one email with bodies, headers, codes, links and attachments |
GET /api/v1/inboxes/:id/emails/:email_id/attachments/:index | Download an attachment |
DELETE /api/v1/inboxes/:id/emails | Delete every email, keep the inbox |
DELETE /api/v1/inboxes/:id/emails/:email_id | Delete one email |
POST /api/v1/inboxes/:id/messages | Store a message in the inbox over HTTP |
Forwarding received email to webhooks has its own routes: see the Email Forwards API. Inboxes created without an account are read through the Anonymous Inboxes API.
Limits and retention
- Free: 1 inbox. Each inbox accepts up to 10 emails in any rolling 24 hours; mail over the limit is rejected until older emails leave the window or you clear the inbox.
- Pro: 5 inboxes. Production and Scale: unlimited inboxes. All paid plans accept unlimited emails per inbox.
- Retention: emails are kept as long as your plan keeps request history (Free 1 day, Pro 14 days, Production 30 days, Scale 90 days), then deleted automatically.
- API keys, and therefore this API, are available on paid plans. On Free, use the MCP server or an inbox without an account.
The inbox object
{
"id": "5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c",
"name": "Signup tests",
"slug": "signup-tests-x1y2",
"created_at": "2026-09-26T10:00:00Z",
"updated_at": "2026-09-26T10:00:00Z",
"email_count": 3
}
Mail sent to <slug>[email protected] lands in the same inbox. Give every test run its own plus-address and filter by it with to= so parallel runs never read each other's mail.
List inboxes
GET /api/v1/inboxes
curl https://app.hooklistener.com/api/v1/inboxes \
-H "Authorization: Bearer hklst_your_api_key"
Returns {"data": [inbox, …]}.
Create an inbox
POST /api/v1/inboxes
curl -X POST https://app.hooklistener.com/api/v1/inboxes \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Signup tests"}'
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Inbox name |
slug | string | No | Local part of the address: 3 to 100 lowercase letters, numbers and hyphens, unique across Hooklistener. Generated from the name if omitted |
The body can also be wrapped as {"inbox": {"name": "…", "slug": "…"}}.
Returns 201 with {"data": inbox} and a Location header.
Errors:
| Status | When |
|---|---|
403 | Your plan has no email inboxes, or you reached the inbox limit. The body includes error and an upgrade object |
422 | Invalid attributes, e.g. {"errors": {"name": ["can't be blank"]}} |
Get an inbox
GET /api/v1/inboxes/:id
Returns {"data": inbox}.
Rename an inbox
PATCH /api/v1/inboxes/:id
curl -X PATCH https://app.hooklistener.com/api/v1/inboxes/INBOX_ID \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Signup tests (staging)"}'
Only name changes. The slug is the inbox's address and can't change: sending a different slug returns 400. Create a new inbox for a new address.
Returns {"data": inbox}.
Delete an inbox
DELETE /api/v1/inboxes/:id
Deletes the inbox, every email in it and its webhook forwards. Mail to its address is no longer received, and the inbox stops counting toward your plan's inbox limit. Returns 204 No Content.
List emails
GET /api/v1/inboxes/:id/emails
curl -G https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/emails \
-H "Authorization: Bearer hklst_your_api_key" \
Newest first, paginated like captured requests: page (default 1) and page_size (default 50, max 100).
Filters (optional, combined):
| Parameter | Description |
|---|---|
since | ISO 8601 timestamp with a UTC offset (emails received at or after that second), or an email ID from this inbox (emails after it) |
to | Exact recipient address, case-insensitive. Use it with plus-addresses |
from | Case-insensitive substring of the From header |
subject_contains | Case-insensitive substring of the subject |
A malformed since, or an email ID from another inbox, returns 400.
Response:
{
"data": [
{
"id": "b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10",
"inbox_id": "5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c",
"subject": "Your verification code",
"size_bytes": 5821,
"has_text_body": true,
"has_html_body": true,
"text_preview": "Your verification code is 482913. It expires in 10 minutes.",
"html_preview": "<p>Your verification code is <b>482913</b>…",
"created_at": "2026-09-26T10:02:11Z"
}
],
"pagination": { "page": 1, "page_size": 50, "total_count": 1, "total_pages": 1 }
}
Previews are capped at 500 bytes. Fetch the email for the full bodies.
Get an email
GET /api/v1/inboxes/:id/emails/:email_id
Returns {"data": email}: the list fields (without the previews) plus:
| Field | Description |
|---|---|
code | The likely one-time code: the first entry of codes, or null |
verification_link | The link most likely to confirm the address, sign in with a magic link, reset a password or accept an invite; null when no link looks like one |
codes | Unique standalone runs of 4 to 8 digits, from the subject first and then the body |
links | Unique http(s) URLs: href links of the HTML body first, then bare URLs in the text body, then bare URLs in the HTML body's text |
text | The text body, or the HTML body rendered as plain text when there is no text body |
text_body, html_body | The bodies as sent |
headers | Parsed headers as an object with lowercase names, first value of each |
header_list | Every header in order, repeated ones (Received, DKIM-Signature) included, as {"name", "value"} objects |
attachments | Attachments and inline images (see below) |
{
"data": {
"id": "b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10",
"inbox_id": "5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c",
"subject": "Confirm your email",
"size_bytes": 58210,
"has_text_body": true,
"has_html_body": true,
"created_at": "2026-09-26T10:02:11Z",
"text_body": "Your code is 482913.\n\nOr confirm here: https://acme.test/verify?token=8f2c1e9a7b6d5c4e3f2a",
"html_body": "<p>Your code is <b>482913</b>.</p><p><a href=\"https://acme.test/verify?token=8f2c1e9a7b6d5c4e3f2a\">Confirm your email</a></p>",
"text": "Your code is 482913.\n\nOr confirm here: https://acme.test/verify?token=8f2c1e9a7b6d5c4e3f2a",
"header_list": [
{ "name": "Subject", "value": "Confirm your email" }
],
"codes": ["482913"],
"code": "482913",
"links": ["https://acme.test/verify?token=8f2c1e9a7b6d5c4e3f2a"],
"verification_link": "https://acme.test/verify?token=8f2c1e9a7b6d5c4e3f2a",
"attachments": [
{
"index": 0,
"filename": "welcome.pdf",
"content_type": "application/pdf",
"size_bytes": 48211,
"content_id": null,
"disposition": "attachment",
"resource_uri": "hooklistener://email/messages/b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10/attachments/0",
"download_url": "https://app.hooklistener.com/api/v1/inboxes/5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c/emails/b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10/attachments/0"
}
]
}
}
How codes and links are picked
codes: numbers that are part of dates, times, versions, amounts, phone numbers or URLs are skipped, socodes[0]is usually the one-time code. Codes split by spaces (482 913) are not detected.verification_link: links are scored by their anchor text (or the text before a bare URL) and their URL. Words like verify, confirm, activate, magic, reset, invite and accept count most; sign in, log in, password and token count less. Unsubscribe, preferences, "view in browser", social profiles, app store badges, images and tracking pixels never count.
Both are heuristics. The full bodies are always included if you need your own pattern.
Attachments
Each entry of attachments has:
| Field | Description |
|---|---|
index | Position in the list; use it to download the attachment |
filename | File name, or null |
content_type | MIME type, e.g. application/pdf |
size_bytes | Decoded size |
content_id | Content-ID of an inline image, or null |
disposition | attachment or inline |
download_url | REST URL that returns the bytes (needs an API key) |
resource_uri | MCP resource for the same bytes |
Wait for the next email
GET /api/v1/inboxes/:id/emails/wait
Long-polls until an email matching the filters arrives, then returns it with 200 and the same body as Get an email. Accepts the same filters as List emails plus:
| Parameter | Default | Description |
|---|---|---|
timeout | 30 | Seconds to wait, up to 60. 0 returns immediately |
- If
sinceis given and a matching email already arrived after it, that email (the oldest match) is returned at once. - Without
since, only emails that arrive during the call count. Record the time before you trigger the email and pass it assinceso you can't miss a fast email. - On timeout the API returns
204 No Contentwith an empty body. Call it again to keep waiting.
curl -sf -G https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/emails/wait \
-H "Authorization: Bearer hklst_your_api_key" \
--data-urlencode "since=2026-09-26T10:02:00Z" -d timeout=60 \
| jq -r '.data.code'
Download an attachment
GET /api/v1/inboxes/:id/emails/:email_id/attachments/:index
curl -f -o welcome.pdf \
https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/emails/EMAIL_ID/attachments/0 \
-H "Authorization: Bearer hklst_your_api_key"
Returns the attachment's bytes with its content type and Content-Disposition: attachment (the file's name, or attachment-<index> plus an extension). Attachments are never rendered inline. An unknown index returns 404. Attachments are kept as long as their email.
Delete emails
DELETE /api/v1/inboxes/:id/emails
Deletes every email in the inbox and keeps the inbox and its address. On Free, the inbox's 10-email allowance frees up at once.
{ "data": { "inbox_id": "5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c", "deleted_count": 12 } }
DELETE /api/v1/inboxes/:id/emails/:email_id
Deletes one email. Returns 204 No Content, or 404 if it's already gone.
Store a message
POST /api/v1/inboxes/:id/messages
Stores a message in the inbox without sending real mail: for test harnesses, or to exercise webhook forwards. The message is handled like received mail: it counts toward the per-inbox limit, wakes up waits and is forwarded.
Send JSON fields:
curl -X POST https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/messages \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "Your verification code",
"text": "Your verification code is 482913.",
"html": "<p>Your verification code is <b>482913</b>.</p>",
"headers": {"X-Run": "42"}
}'
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Sender, e.g. Acme <[email protected]> |
to, cc, bcc | string or array | No | Recipients, up to 100 in total. Bcc recipients are kept but not written into the headers. With no recipients, the message is addressed to the inbox |
subject | string | No | Subject |
text, html | string | No | Bodies |
headers | object | No | Extra headers as strings. They can't replace From, To, Cc, Bcc, Subject or the MIME headers |
Or send a raw MIME message, for example one with attachments, with Content-Type: message/rfc822; recipients come from its To, Cc and Bcc headers:
curl -X POST https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/messages \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: message/rfc822" \
--data-binary @welcome.eml
Returns 201 with {"data": email} (the list fields) and a Location header pointing at the stored email. Needs the execute API key scope, which keys have by default.
Errors:
| Status | When |
|---|---|
413 | The message is over 10 MB |
415 | The content type is neither application/json nor message/rfc822 |
422 | Invalid fields, e.g. a missing from |
429 | The inbox reached its plan's email limit. The body includes error and an upgrade object |
From an AI agent
The same inboxes are available to Claude Code, Codex, Cursor and other agents through the MCP server (create_inbox, wait_for_email, get_email, delete_emails…). See Test emails with AI agents.