All articles
EmailAugust 13, 2026 · 8 min read

How to receive OTP emails in automated tests

Give your test suite a real email address that turns every message into a webhook capture: await the OTP, extract the code, finish the signup flow - no IMAP polling.


Every signup flow ends the same way: an email lands somewhere, and your test has to read it. Verification codes, magic links, password resets - the assertion you actually care about lives in an inbox, and inboxes are terrible test infrastructure. IMAP polling is slow and flaky. Gmail plus-addressing piles state into one real mailbox. Hosted inbox APIs are another vendor, another SDK, another secret to rotate.

The shape of a better fix: stop treating email as email. If a message could arrive the way a webhook arrives - as a structured JSON capture you can await and assert on - the OTP problem collapses into a problem you've already solved.

An email address in one command

Every OtterKit webhook endpoint can receive mail. Turn the inbox on and the endpoint's subdomain becomes an address:

$ npx otterkit webhook --email
  Webhook ready: https://hook-e5f6g7h8.otterkit.app
  Inbox    [email protected] - mail lands in the feed like a request

Each message lands in the endpoint's capture feed with method EMAIL, right next to any HTTP traffic. The body is a JSON envelope - from, to, subject, text, html, attachment metadata - so everything that works on requests works on mail: awaiting, forwarding rules, stored history, notifications.

For a test suite you want a stable address, so reserve the name once:

bash
npx otterkit webhook --daemon --subdomain signup-test --ttl never --email
# → [email protected], every run

Await the OTP

await blocks until a matching capture arrives, then prints it. Filter on --method EMAIL and it becomes wait-for-the-verification-email:

bash
# Block until the email lands: exit 0 on arrival, exit 2 after 2 minutes
npx otterkit await signup-test --method EMAIL --count 1 --timeout 120s --json

With --json the message prints as a single JSON line. From there, extracting the code is a one-liner - the test never touches IMAP, OAuth, or a mail client:

bash
# Sign up with [email protected] in your test, then:
CODE=$(npx otterkit await signup-test --method EMAIL --count 1 --timeout 120s --json \
  | jq -r '.body' | base64 -d | jq -r '.text' | grep -oE '[0-9]{6}' | head -1)

# feed $CODE back into the flow under test

The full loop in an end-to-end test reads the way you'd describe it out loud:

Exit codes are deterministic - 0 when the message arrived, 2 when the timeout expired - so a missing email fails the build with a real signal instead of a mystery timeout three layers up the stack.

The same trick, for agents

AI agents hit this constantly: they sign up for something, and the confirmation lands in an inbox they don't have. Over OtterKit's MCP server, an agent mints the inbox and awaits the message with two tool calls - same primitives, no shelling out:

text
webhook_create { "subdomain": "signup-test", "email": true }
→ [email protected]

request_await { "subdomain": "signup-test", "method": "EMAIL" }
→ { "subject": "Your verification code", "text": "Code: 481-923", ... }

Hand the address to whatever service needs it, block on request_await, read the code out of the envelope. The agent finishes signup flows unattended.

Turn the inbox into a webhook

Forwarding rules match emails too. A rule with method=EMAIL delivers every message to your URL as JSON - an email-to-HTTP bridge with no mail server anywhere:

bash
npx otterkit webhook --email --match method=EMAIL \
  --forward https://ci.example.com/hooks/inbound-mail

Deliveries are HMAC-signed (X-OtterKit-Signature) and retried with backoff, so the receiving side can trust and depend on them like any webhook.

Semantics worth knowing

Nothing is silently dropped: mail to a disabled inbox, an expired endpoint, or an address that doesn't exist is rejected at SMTP time, so the sender gets a normal bounce with the reason. The stored envelope is capped at 64 KB (long bodies truncate, HTML first), and attachment contents are never stored - names, types, and sizes only. For OTPs and magic links, none of that matters; the code is always in the first kilobyte.

The inbox rides on a normal webhook endpoint: 1 credit ($0.01) per hour, never more than $3 per rolling 30 days. Details in the email inboxes docs.