Quickstart
InboxWire gives you throwaway inboxes over a REST API. The usual flow is three calls: create an inbox, do the thing that sends an email, then block until it arrives.
# 1 โ create an inbox curl -X POST https://emailcheck.in/v1/inboxes \ -H "Authorization: Bearer iw_live_xxx" # { "id": "ibx_7fk29dq4mzp1", "address": "k7m2xq9wd4tn@emailcheck.in", ... } # 2 โ block until mail lands (returns instantly when it does) curl "https://emailcheck.in/v1/inboxes/ibx_7fk29dq4mzp1/wait?timeout=30" \ -H "Authorization: Bearer iw_live_xxx" # { "id": "msg_...", "subject": "Verify your email", "otp": "418302", ... }
const KEY = process.env.INBOXWIRE_KEY; const api = (path, init) => fetch(`https://emailcheck.in${path}`, { ...init, headers: { Authorization: `Bearer ${KEY}`, 'content-type': 'application/json' }, }).then(r => r.json()); const inbox = await api('/v1/inboxes', { method: 'POST' }); await signUpWith(inbox.address); const msg = await api(`/v1/inboxes/${inbox.id}/wait?timeout=30`); console.log(msg.otp, msg.links);
import os, requests KEY = os.environ["INBOXWIRE_KEY"] H = {"Authorization": f"Bearer {KEY}"} BASE = "https://emailcheck.in" inbox = requests.post(f"{BASE}/v1/inboxes", headers=H).json() sign_up(inbox["address"]) msg = requests.get( f"{BASE}/v1/inboxes/{inbox['id']}/wait", params={"timeout": 30}, headers=H, timeout=35, ).json() assert msg["otp"]
Authentication
Every /v1/* request needs your API key as a bearer token. Keys look like iw_live_โฆ and are shown once, when created โ we only store a hash, so if you lose one, revoke it and issue another from the dashboard.
Authorization: Bearer iw_live_9f2a... # preferred X-Api-Key: iw_live_9f2a... # also accepted
Keys are per-account, not per-inbox. Treat them like passwords: environment variables and CI secrets, never committed.
Errors & quotas
Errors are JSON with a stable machine-readable error string and a human message.
{ "error": "plan_limit", "message": "ttl_seconds exceeds your plan maximum of 3600s (1h)." }
| Status | Meaning |
|---|---|
400 | Malformed request โ bad localpart, unknown domain, invalid TTL. |
401 | Missing, malformed, revoked or unknown API key. |
402 | Plan limit hit โ quota exhausted, too many keys or live inboxes. |
404 | Inbox or message not found. Expired inboxes are indistinguishable from ones that never existed. |
408 | /wait reached its timeout with no message. |
409 | address_taken โ that localpart@domain is already live. |
429 | Rate limited. Back off and retry. |
Every /v1/* response carries your live quota so you never have to guess:
X-Quota-Limit: 5000 X-Quota-Used: 1284 X-Quota-Remaining: 3716 X-Quota-Period: 2026-08
Quota counts emails received per UTC calendar month. Creating inboxes is free and unmetered.
Account
Plan, limits and current usage. Handy as a health check in CI before a suite runs.
{
"account": { "id": "acc_...", "email": "dev@example.com" },
"plan": { "id": "starter", "emails_per_month": 5000, "webhooks": true, "custom_domain": false },
"usage": { "period": "2026-08", "emails_used": 1284, "emails_remaining": 3716 },
"domains": ["emailcheck.in"]
}
Create an inbox
All fields optional. With an empty body you get a random address on the default domain with your plan's default TTL.
| Field | Type | Notes |
|---|---|---|
localpart | string | The part before the @. 2โ63 chars of a-z 0-9 . _ -. Random if omitted. |
domain | string | One of our domains, or your verified custom domain (Pro). |
ttl_seconds | integer | Minimum 60. Capped by plan: 1h Free, 24h Starter, 7d Pro. |
webhook_url | string | HTTPS URL, POSTed on each message. Starter and up. |
curl -X POST https://emailcheck.in/v1/inboxes \ -H "Authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"localpart":"ci-run-8842","ttl_seconds":3600,"webhook_url":"https://ci.example.com/hook"}' โ 201 { "id": "ibx_7fk29dq4mzp1", "address": "ci-run-8842@emailcheck.in", "localpart": "ci-run-8842", "domain": "emailcheck.in", "message_count": 0, "created_at": "2026-08-12T09:14:02.114Z", "expires_at": "2026-08-12T10:14:02.114Z", "expires_in_seconds": 3600 }
Deterministic localparts make debugging a failed CI run much easier โ name them after the build, e.g. ci-${GITHUB_RUN_ID}. You get a 409 address_taken if that address is still live, which is a useful signal that a previous run leaked.
List inboxes
Your live (unexpired) inboxes, newest first. limit defaults to 100, max 500.
{ "data": [ { "id": "ibx_...", "address": "...", "expires_in_seconds": 2841 } ] }
Retrieve an inbox
Returns the same shape as creation. 404 once expired.
Extend an inbox
Pushes expires_at out by ttl_seconds (default 3600) from now, subject to your plan cap.
curl -X POST https://emailcheck.in/v1/inboxes/ibx_7fk29dq4mzp1/extend \ -H "Authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"ttl_seconds": 7200}'
Delete an inbox
Immediately drops the inbox and every message in it. Good hygiene in a test afterEach โ it frees a slot against your live-inbox cap.
{ "deleted": true, "id": "ibx_7fk29dq4mzp1" }
List messages
Newest first, without bodies โ this is the cheap listing call. Fetch a single message for text and html.
{
"data": [{
"id": "msg_3xk9wq2mdp4t",
"inbox_id": "ibx_7fk29dq4mzp1",
"from": "noreply@yourapp.com",
"from_name": "YourApp",
"to": "ci-run-8842@emailcheck.in",
"subject": "Verify your email",
"otp": "418302",
"links": ["https://yourapp.com/verify?t=abc123"],
"attachments": [],
"size_bytes": 8412,
"received_at": "2026-08-12T09:14:09.802Z"
}]
}
Wait for a message โ
The endpoint you actually came for. It holds the connection open and returns the moment a message arrives โ full body included. No polling loop, no sleep(5) that's flaky on a slow CI runner and wasteful on a fast one.
timeoutโ seconds to wait, default 30, max 120. Set your HTTP client's timeout a few seconds higher.sinceโ ISO timestamp; only returns messages newer than this. Defaults to the inbox's creation time, so there is no race: if the email landed in the gap between creating the inbox and calling/wait, you still get it. Pass the previous message'sreceived_atto page past it.
Returns 200 with the message, or 408 if nothing arrived in time.
// second email in the same flow: pass the first one's timestamp const first = await api(`/v1/inboxes/${id}/wait?timeout=30`); const second = await api(`/v1/inboxes/${id}/wait?timeout=30&since=${first.received_at}`);
Retrieve / delete a message
Full message including text and sanitised html.
Removes a single message.
About the HTML. We strip scripts, event handlers, iframes, objects and all <img> tags โ so tracking pixels never fire โ and force every link to rel="noopener noreferrer nofollow". If you need the literal untouched source, assert against text instead.
Usage
{
"current_period": "2026-08",
"limit": 5000,
"history": [ { "period": "2026-08", "emails": 1284 }, { "period": "2026-07", "emails": 4903 } ]
}
Webhooks
Set webhook_url when creating an inbox (Starter and up) and we POST each message to it as it lands. Useful when the thing consuming mail isn't the thing that created the inbox.
POST https://your-endpoint.example.com/hook
content-type: application/json
user-agent: InboxWire-Webhook/1.0
x-inboxwire-event: message.received
{ "event": "message.received", "inbox_id": "ibx_...", "message": { "otp": "418302", ... } }
- HTTPS only. We time out after 8 seconds and do not retry โ treat delivery as best-effort and use /wait when you need a guarantee.
- Respond
2xxquickly; do your work asynchronously. - Delivery attempts and their status codes are logged and visible on request.
Test-suite recipes
Playwright โ email verification, end to end
import { test, expect } from '@playwright/test'; const KEY = process.env.INBOXWIRE_KEY; const iw = (p, init = {}) => fetch(`https://emailcheck.in${p}`, { ...init, headers: { Authorization: `Bearer ${KEY}` } }).then(r => r.json()); test('user can verify their email', async ({ page }) => { const inbox = await iw('/v1/inboxes', { method: 'POST' }); await page.goto('/signup'); await page.fill('#email', inbox.address); await page.fill('#password', 'correct-horse-battery'); await page.click('button[type=submit]'); const mail = await iw(`/v1/inboxes/${inbox.id}/wait?timeout=45`); expect(mail.subject).toContain('Verify'); await page.goto(mail.links[0]); // or: fill in mail.otp await expect(page.locator('.badge')).toHaveText('Verified'); await iw(`/v1/inboxes/${inbox.id}`, { method: 'DELETE' }); });
Cypress โ reading a one-time code
Cypress.Commands.add('tempInbox', () => cy.request({ method: 'POST', url: 'https://emailcheck.in/v1/inboxes', headers: { Authorization: `Bearer ${Cypress.env('IW_KEY')}` }, }).its('body')); Cypress.Commands.add('waitForOtp', (id) => cy.request({ url: `https://emailcheck.in/v1/inboxes/${id}/wait?timeout=60`, headers: { Authorization: `Bearer ${Cypress.env('IW_KEY')}` }, timeout: 65000, }).its('body.otp')); it('logs in with a magic code', () => { cy.tempInbox().then((inbox) => { cy.visit('/login'); cy.get('#email').type(inbox.address); cy.contains('Send code').click(); cy.waitForOtp(inbox.id).then((otp) => { cy.get('#code').type(otp); cy.contains('Welcome back').should('be.visible'); }); }); });
pytest fixture
import os, requests, pytest BASE, H = "https://emailcheck.in", {"Authorization": f"Bearer {os.environ['INBOXWIRE_KEY']}"} @pytest.fixture def inbox(): box = requests.post(f"{BASE}/v1/inboxes", headers=H).json() yield box requests.delete(f"{BASE}/v1/inboxes/{box['id']}", headers=H) def wait_for_mail(box, timeout=30): r = requests.get(f"{BASE}/v1/inboxes/{box['id']}/wait", params={"timeout": timeout}, headers=H, timeout=timeout + 5) assert r.status_code == 200, "no email arrived" return r.json() def test_password_reset(inbox, client): client.post("/reset", json={"email": inbox["address"]}) assert wait_for_mail(inbox)["otp"]
GitHub Actions
- name: E2E
env:
INBOXWIRE_KEY: ${{ secrets.INBOXWIRE_KEY }}
run: npm run test:e2e
Custom domains (Pro)
Every public disposable-mail domain eventually lands on a blocklist โ that's the nature of the category, and it's why your tests suddenly start failing against your own signup form. On Pro you point a domain you own at InboxWire, so your test addresses look exactly like real user addresses and nothing rejects them.
- Add an MX record for the domain (or a subdomain like
mail.yourcompany.com) pointing at our inbound host. - We verify it, then pass
"domain": "mail.yourcompany.com"to POST /v1/inboxes. - Use a subdomain you don't use for real mail โ the catch-all accepts every address under it.
Email support@emailcheck.in and we'll set it up with you; it takes about ten minutes.
Limits
| Free | Starter | Pro | |
|---|---|---|---|
| Emails / month | 10 | 5,000 | 50,000 |
| Live inboxes | 3 | 200 | 5,000 |
| Max inbox lifetime | 1 hour | 24 hours | 7 days |
| API keys | 1 | 3 | 20 |
| Webhooks | โ | โ | โ |
| Attachments | โ | โ | โ |
| Custom domain | โ | โ | โ |
Other hard limits: 5 MB per message, 100 messages retained per inbox (oldest dropped), 300 API requests per minute. Need more? Ask โ dedicated plans exist.