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.
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 |
GET /api/v1/inboxes/:id/emails | List emails, newest first, with filters |
GET /api/v1/inboxes/:id/emails/:email_id | Get one email with bodies, headers, links and codes |
GET /api/v1/inboxes/:id/emails/wait | Wait for the next matching email |
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.
- 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 webhook 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.
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; must be globally unique. 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}.
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-…",
"inbox_id": "5c1d0e9a-…",
"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 plus text_body, html_body, headers (parsed headers), links and codes.
links: uniquehttp(s)URLs. Links from the HTML body'shrefattributes come first, then bare URLs in the text body. Use it to open a magic link or check that a link isn't broken.codes: unique standalone runs of 4 to 8 digits, from the subject first and then the body. Numbers that are part of dates, times, versions, amounts or URLs are skipped, socodes[0]is usually the one-time code. Codes split by spaces (482 913) are not detected.
Both are simple heuristics. The full bodies are always included if you need your own pattern.
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.
Example: verify a signup email in CI
curl
Create the inbox once (by hand, in a setup job, or with an agent) and store its ID and address as CI secrets. Creating one per run would quickly hit your plan's inbox limit.
# One-time setup
curl -sf -X POST https://app.hooklistener.com/api/v1/inboxes \
-H "Authorization: Bearer $HOOKLISTENER_API_KEY" \
-H "Content-Type: application/json" -d '{"name": "ci-signup"}' \
| jq '.data | {id, email_address}'
# Save them as HOOKLISTENER_INBOX_ID and HOOKLISTENER_INBOX_ADDRESS
Each run then uses its own plus-address and a start time:
API=https://app.hooklistener.com/api/v1
AUTH="Authorization: Bearer $HOOKLISTENER_API_KEY"
ADDRESS=$(echo "$HOOKLISTENER_INBOX_ADDRESS" | sed "s/@/+run-$GITHUB_RUN_ID@/")
SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
./scripts/sign-up.sh "$ADDRESS"
CODE=$(curl -sf -G "$API/inboxes/$HOOKLISTENER_INBOX_ID/emails/wait" -H "$AUTH" \
--data-urlencode "to=$ADDRESS" --data-urlencode "since=$SINCE" \
-d timeout=60 | jq -r '.data.codes[0] // empty')
test -n "$CODE" || { echo "no verification email"; exit 1; }
curl -f treats 204 as success, so check that the output isn't empty.
Playwright
import { test, expect } from "@playwright/test";
const API = "https://app.hooklistener.com/api/v1";
const headers = { authorization: `Bearer ${process.env.HOOKLISTENER_API_KEY}` };
const INBOX_ID = process.env.HOOKLISTENER_INBOX_ID!;
const INBOX_ADDRESS = process.env.HOOKLISTENER_INBOX_ADDRESS!;
async function waitForEmail(params: Record<string, string>) {
const query = new URLSearchParams({ timeout: "60", ...params });
for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetch(`${API}/inboxes/${INBOX_ID}/emails/wait?${query}`, { headers });
if (res.status === 200) return (await res.json()).data;
if (res.status !== 204) throw new Error(`wait failed: ${res.status}`);
}
throw new Error("no email arrived");
}
test("signup emails a working code", async ({ page }) => {
// Two 60-second waits plus the signup; Playwright's default is 30 seconds.
test.setTimeout(150_000);
const address = INBOX_ADDRESS.replace("@", `+${Date.now()}@`);
const since = new Date().toISOString();
await page.goto("http://localhost:3000/signup");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Create account" }).click();
const email = await waitForEmail({ to: address, since });
expect(email.subject).toContain("verification code");
await page.getByLabel("Verification code").fill(email.codes[0]);
await expect(page.getByText("Verified")).toBeVisible();
});
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). See the guide Test signup, magic-link and OTP emails with AI agents.