OTPBox
Gives AI agents and test suites a disposable email inbox: create test identities, wait for mail, and extract OTP codes and verification links automatically over REST or MCP.
Hosted MCP Server
npx add-mcp 'https://otpbox.io/mcp'Installs into Claude Code, Codex, Cursor and more
Documentation
OTPBox developer API documentation
Create disposable inboxes and read one-time codes from a test suite, script, or backend job. Start with a free key — no account needed — or create an organization to share keys with a team and upgrade.
Try it now
Live against the real API, right from this page — no signup. Mints a real (rate-limited) free key, creates a real inbox, then checks it for a message. Send the inbox address a real email from another tab to see it show up.
Click "Mint a free key" to start.
Get a key
Free, 200 requests/month, simple bearer-token auth. Mint one instantly, no payment or account:
curl -X POST https://otpbox.io/api/v1/keys/free \
-H "content-type: application/json" \
-d '{}'
→ { "id": "lic_...", "key": "...", "plan": "free", "quotaLimit": 200 }
Save the returned key — like an inbox token, it's shown once and never stored anywhere you can read it back from. Limited to 5 free keys/day per IP.
Sandbox keys for CI. POST /api/v1/keys/sandbox mints a key the same way (no account, same 200 requests/month, its own 5/day/IP limit) and returns { "id": "lic_...", "key": "...", "plan": "free", "quotaLimit": 200, "sandbox": true }. With a sandbox key, POST /api/v1/inboxes (and the MCP create_test_inbox tool) skips real mail: the new inbox already contains one synthesized message from noreply@sandbox.otpbox.io with a random 6-digit code, so a test run is fully deterministic and needs no real delivery. Sandbox inboxes never receive real mail, so they never fire message.received, otp.extracted or link.detected webhooks.
Plans
Quotas are per calendar month. Organization plans pool the quota across every key the organization mints; a personal free key has its own. Full details on the pricing page.
| Plan | Requests / month | Price | How to get it |
|---|---|---|---|
| Personal free key | 200 per key | $0 | POST /api/v1/keys/free, no account |
| Organization — Free | 200, pooled | $0 | Sign up, create an organization, mint keys per project |
| Organization — Pro | 5,000, pooled | $9 / month | Dashboard → your organization → Plan & usage → Upgrade to Pro |
| Enterprise | Custom | Custom | Tell us about your use case |
Check what a key is entitled to at any time with GET /api/v1/usage — it returns the plan, the governing limit, and how much of it has been used this month.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer <your-key>
Endpoints
All paths are under https://otpbox.io. Every route except the two key-minting routes needs Authorization: Bearer <key>, and every authenticated request counts as one request against your monthly quota. Timestamps are Unix milliseconds. Bodies and responses are JSON.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/keys/free | Mint a free key (no auth; 5/day/IP). Returns { id, key, plan, quotaLimit }. |
| POST | /api/v1/keys/sandbox | Mint a sandbox key (no auth; 5/day/IP). |
| POST | /api/v1/inboxes | Create an inbox. Body: { domain?, local? }. Returns 201 { id, address, domain, createdAt, expiresAt, token }. Scope inbox:create. Accepts an Idempotency-Key. |
| GET | /api/v1/inboxes/:id | Inbox metadata: { id, address, domain, createdAt, expiresAt }. Only readable by the key that created it. Scope inbox:read. |
| DELETE | /api/v1/inboxes/:id | Delete the inbox and every message in it immediately. Returns { ok: true }. Scope inbox:delete. |
| GET | /api/v1/inboxes/:id/messages | List messages, newest first (up to 200), as { messages: [{ id, from, fromName, subject, code, linkHost, linkType, size, receivedAt, read }] }. Scope message:read. |
| GET | /api/v1/messages/:id | Full message: { id, from, fromName, subject, text, html, code, link, size, receivedAt, attachments, preview } where link is { url, host, type } or null. HTML is sanitized. Scope message:read. |
| GET | /api/v1/usage | Plan, limit and usage this month — see Rate limits and quota. |
| POST | /api/v1/batches | Create up to 50 inboxes in one call — see Batches. Scope bulk:create. |
| GET | /api/v1/batches/:id | A batch's status and per-item results. |
| POST | /api/v1/identities | Create a test identity: a synthetic persona backed by a real inbox. |
| POST | /api/v1/identities/bulk | Create up to 50 test identities in one call. |
| GET | /api/v1/identities/:id | Fetch one test identity (the inbox token is never re-shown). |
| DELETE | /api/v1/identities/:id | Delete an identity and its backing inbox. |
| GET | /api/v1/webhooks | List this key's webhooks. Scope webhook:manage. |
| POST | /api/v1/webhooks | Register a webhook. Scope webhook:manage. |
| DELETE | /api/v1/webhooks/:id | Delete a webhook. Scope webhook:manage. |
| GET | /api/v1/metrics | Call volume, error rate and latency of this key's MCP tool calls over the last 24 hours and 7 days: { last24h, last7d }, each with totalCalls, errorRatePct, avgLatencyMs, p95LatencyMs and a per-tool byTool breakdown. |
Inboxes are deleted when they expire — 1 hour after creation by default — or as soon as you delete them. The message list only reports what was extracted (code, linkHost, linkType); fetch the full message for the link URL. linkType (and link.type) is one of verification, password_reset, magic_login, unsubscribe, tracking or general.
Example message response:
{
"id": "msg_a1b2c3d4e5f6",
"from": "noreply@example.com",
"fromName": "Example",
"subject": "Your verification code",
"text": "Your code is 482913",
"html": null,
"code": "482913",
"link": { "url": "https://example.com/verify?t=...", "host": "example.com", "type": "verification" },
"size": 2048,
"receivedAt": 1790000000000,
"attachments": [],
"preview": "Your code is 482913"
}
Example: get a code in a Playwright test
const key = process.env.OTPBOX_KEY;
const res = await fetch('https://otpbox.io/api/v1/inboxes', {
method: 'POST',
headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' },
body: '{}',
});
const inbox = await res.json();
// use inbox.address to sign up, then poll:
const msgs = await fetch('https://otpbox.io/api/v1/inboxes/' + inbox.id + '/messages', {
headers: { authorization: 'Bearer ' + key },
}).then((r) => r.json());
const code = msgs.messages[0]?.code;
Batches
Create up to 50 inboxes in a single call — handy for provisioning a whole test matrix up front. The batch runs synchronously and returns every address and token in the response.
POST /api/v1/batches
{ "count": 3, "ttlHours": 2 }
→ 201
{
"id": "batch_...",
"status": "partial",
"requestedCount": 3,
"items": [
{ "id": "a1b2c3d4e5f6", "status": "completed", "address": "bold.quartz844@otpbox.io", "token": "...", "expiresAt": 1790007200000 },
{ "id": "bi_...", "status": "failed", "error": "address_unavailable" }
]
}
countis required, 1–50; anything else returns400 { "error": "bad_count", "max": 50 }.ttlHoursis optional and capped at the server maximum (currently 3 hours); without it, the default inbox lifetime applies.- Batch
statusiscompleted(every item succeeded),partial(some failed) orfailed(none succeeded). A completed item'sidis the inbox id, so you can read it withGET /api/v1/inboxes/:id/messages. (The example above is abbreviated to two items.) - Each item's
tokenis the inbox's own bearer token. It is only returned by this create call. GET /api/v1/batches/:idreturns{ id, status, requestedCount, createdAt, completedAt, items: [{ id, status, address, expiresAt, error }] }(no tokens);idisnullfor a failed item. Unknown or someone else's batch:404 not_found.- Requires the
bulk:createscope and accepts an Idempotency-Key.503 no_domainsif no mailbox domain is currently available.
Test identities
A test identity is a synthetic persona — name, country and a small profile — backed by a real disposable inbox, so a signup flow under test can use a realistic person and still receive its verification email. Pick a template: us_customer, indian_customer, european_customer, business_customer, student or employee.
POST /api/v1/identities
{ "template": "business_customer", "ttlHours": 2 }
→ 201
{
"id": "ident_...",
"status": "active",
"template": "business_customer",
"name": "Mary Johnson",
"email": "bold.quartz844@otpbox.io",
"country": "US",
"profile": { "company": "Acme Corp", "role": "Product Manager" },
"token": "...",
"createdAt": 1790000000000,
"expiresAt": 1790007200000
}
- Use
emailin the form under test.tokenis the backing inbox's bearer token, shown only in this response; use it with the public inbox API (GET /api/inboxes/me/messageswithAuthorization: Bearer <token>) to read what arrives. - The persona's
profiledepends on the template:business_customeraddscompanyandrole,studentaddsuniversityandmajor,employeeaddscompany,departmentandjobTitle; the customer templates return an empty profile. ttlHoursis optional and capped at the server maximum (currently 3 hours). Errors:400 bad_template(withvalidTemplates),409 address_unavailable,503 no_domains.POST /api/v1/identities/bulkwith{ "template", "count", "ttlHours"? }(count1–50, else400 bad_count) returns201 { batchId, template, requestedCount, items: [...] };batchIdlooks likeidbatch_.... Each item is{ id, status: "active", name, email, country, profile, token, expiresAt }, or{ id, status: "failed", error }.GET /api/v1/identities/:idreturns the identity without its token, plusbatchIdanderror.DELETE /api/v1/identities/:iddeletes the identity and its backing inbox and returns{ ok: true, id, inboxDeleted }. Unknown ids return404 not_found.
Idempotency keys
If a CI job retries a create call after a network timeout, it can end up with two inboxes. Send an Idempotency-Key header on POST /api/v1/inboxes or POST /api/v1/batches and a retry with the same key returns the original response instead of creating another resource.
curl -X POST https://otpbox.io/api/v1/inboxes \
-H "authorization: Bearer $OTPBOX_KEY" \
-H "idempotency-key: signup-test-run-8841" \
-H "content-type: application/json" -d '{}'
- The key is 1–255 characters from
A-Z a-z 0-9 _ . : -(UUIDs work well); anything else returns400 invalid_idempotency_key. The header is optional. - Keys are scoped to your API key and to the endpoint, so the same value can be reused against
/inboxesand/batcheswithout collision. - Both successful and error responses are stored and replayed with the original status code. Records are kept for 24 hours.
- If a request with the same key is still in flight, the second one gets
409 idempotency_key_in_progress; retry after a moment. - A replayed request still counts as one request against your quota. The identities routes do not read this header.
Webhooks
Get an HTTPS POST when something happens instead of polling. Webhooks belong to the API key that registered them and fire for that key's inboxes, identities and usage. Registering, listing and deleting them needs the webhook:manage scope (or a key with no scope restrictions).
Register
POST /api/v1/webhooks
{ "url": "https://your-app.example.com/webhooks/otpbox", "events": ["otp.extracted", "usage.approaching"] }
→ 201
{
"id": "wh_...",
"url": "https://your-app.example.com/webhooks/otpbox",
"events": ["otp.extracted", "usage.approaching"],
"secret": "...",
"createdAt": 1790000000000
}
urlmust start withhttps://, otherwise400 { "error": "bad_url" }.eventsis a list from the catalog below. Unknown names are ignored; if none are valid you get400 { "error": "bad_events", "validEvents": [...] }.- The
secretis returned once, only in this response. Store it now — you need it to verify signatures and it cannot be retrieved later. GET /api/v1/webhooksreturns{ webhooks: [{ id, url, events, status, createdAt }] }(never the secret).statusisactiveordisabled.DELETE /api/v1/webhooks/:idreturns{ ok: true }, or404 not_found.
Event catalog
Every delivery is a POST with content-type: application/json. The body is a JSON object whose event field names the event, followed by the fields shown below. The event name is also sent in the x-otpbox-event header.
| Event | Fires when |
|---|---|
inbox.created | An inbox is created with POST /api/v1/inboxes or the MCP create_test_inbox tool. Not fired for batch or identity inboxes. |
inbox.deleted | An inbox is deleted with DELETE /api/v1/inboxes/:id or the MCP delete_inbox tool. |
inbox.expired | One of your inboxes reaches its expiry and is removed by the cleanup job (which runs every 15 minutes). |
message.received | An email arrives in one of your inboxes. |
otp.extracted | An arriving email contained a one-time code. Same payload as message.received. |
link.detected | An arriving email contained a confirmation or other link. |
identity.created | A test identity is created. A bulk create fires one event for the whole batch, with a batch-shaped payload. |
identity.deleted | A test identity is deleted. |
usage.approaching | Usage first reaches 80% of the monthly limit. Once per billing period. |
usage.exceeded | Usage reaches 100% of the monthly limit. Once per billing period. |
Example payloads (timestamps are Unix milliseconds):
inbox.created
{ "event": "inbox.created", "inboxId": "a1b2c3d4e5f6", "address": "bold.quartz844@otpbox.io", "domain": "otpbox.io", "createdAt": 1790000000000, "expiresAt": 1790003600000 }
inbox.deleted
{ "event": "inbox.deleted", "inboxId": "a1b2c3d4e5f6", "address": "bold.quartz844@otpbox.io", "deletedAt": 1790000900000 }
inbox.expired
{ "event": "inbox.expired", "inboxId": "a1b2c3d4e5f6", "address": "bold.quartz844@otpbox.io", "expiresAt": 1790003600000, "expiredAt": 1790004200000 }
message.received
{ "event": "message.received", "inboxId": "a1b2c3d4e5f6", "messageId": "msg_...", "from": "noreply@example.com", "fromName": "Example", "subject": "Your verification code", "code": "482913", "link": null, "receivedAt": 1790000300000 }
otp.extracted
{ "event": "otp.extracted", "inboxId": "a1b2c3d4e5f6", "messageId": "msg_...", "from": "noreply@example.com", "fromName": "Example", "subject": "Your verification code", "code": "482913", "link": null, "receivedAt": 1790000300000 }
link.detected
{ "event": "link.detected", "inboxId": "a1b2c3d4e5f6", "messageId": "msg_...", "linkUrl": "https://example.com/verify?t=...", "linkHost": "example.com", "linkType": "verification", "receivedAt": 1790000300000 }
identity.created (single identity)
{ "event": "identity.created", "identityId": "ident_...", "template": "us_customer", "email": "bold.quartz844@otpbox.io", "country": "US", "createdAt": 1790000000000, "expiresAt": 1790003600000 }
identity.created (from /identities/bulk)
{ "event": "identity.created", "batchId": "idbatch_...", "template": "student", "requestedCount": 10, "createdCount": 10, "failedCount": 0, "createdAt": 1790000000000 }
identity.deleted
{ "event": "identity.deleted", "identityId": "ident_...", "inboxId": "a1b2c3d4e5f6", "email": "bold.quartz844@otpbox.io", "deletedAt": 1790000900000 }
usage.approaching and usage.exceeded
{ "event": "usage.approaching", "orgId": "org_...", "licenseId": "lic_...", "scope": "organization", "period": "2026-09", "used": 160, "limit": 200, "percent": 80 }
For an organization key the threshold is measured against the organization's pooled quota, and orgId and scope: "organization" are included; every key in the organization that has a subscribed webhook gets the event. For a personal key those two fields are omitted and used / limit are that key's own. usage.exceeded has the same shape with a percent of 100 or more.
Verify the signature
Each delivery carries an x-otpbox-signature header: the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's secret. Compute it over the exact bytes you received (before any JSON parsing) and compare in constant time. Reject anything that doesn't match.
// Node (Express)
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/otpbox', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', process.env.OTPBOX_WEBHOOK_SECRET)
.update(req.body) // Buffer: the raw body
.digest('hex');
const given = String(req.get('x-otpbox-signature') ?? '');
const a = Buffer.from(expected);
const b = Buffer.from(given);
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
if (event.event === 'otp.extracted') console.log('code:', event.code);
res.status(200).end();
});
# Python (Flask)
import hashlib, hmac, os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post('/webhooks/otpbox')
def otpbox_webhook():
raw = request.get_data() # bytes: the raw body
expected = hmac.new(os.environ['OTPBOX_WEBHOOK_SECRET'].encode(), raw, hashlib.sha256).hexdigest()
given = request.headers.get('x-otpbox-signature', '')
if not hmac.compare_digest(expected, given):
abort(401)
event = request.get_json(force=True)
if event['event'] == 'otp.extracted':
print('code:', event['code'])
return '', 200
The otpbox-sdk package ships the same check as verifyWebhookSignature(secret, rawBody, signatureHeader).
Delivery, retries and auto-disable
- A delivery succeeds when your endpoint answers with a 2xx status within 8 seconds. Anything else — another status, a timeout, a connection error — is a failure.
- The first attempt is sent immediately. A failed delivery is retried up to 5 attempts in total, waiting 5 minutes, 15 minutes, 1 hour and 4 hours after attempts 1 to 4. Retries are picked up by a job that runs every 15 minutes, so a retry can arrive up to 15 minutes later than these nominal delays. Each retry re-sends the identical body, so deduplicate on the payload (for example
messageId) if your handler isn't idempotent. - After 10 consecutive failed attempts the webhook is marked
disabled(visible inGET /api/v1/webhooks) and stops receiving events; any successful delivery resets the counter. Once your endpoint is healthy again, re-enable it from the dashboard (Projects & keys → the key → Webhooks → Enable, then Send test to confirm) or register a new one withPOST /api/v1/webhooks. The dashboard also sends awebhook.testevent on demand and lets you redeliver any past delivery. - Deliveries are not ordered. Answer quickly with a 2xx and do heavy work afterwards.
CI/CD: OTPs in GitHub Actions
The most common setup: a Playwright or Cypress E2E suite that signs up a real account and needs a real code to get past the OTP screen. The otpbox-sdk package (npm install otpbox-sdk) wraps the endpoints above in createInbox() / waitForOtp() / deleteInbox() so your workflow and test code don't hand-roll fetch calls.
# .github/workflows/e2e.yml
name: E2E
on: [push]
jobs:
e2e:
runs-on: ubuntu-latest
env:
OTPBOX_KEY: ${{ secrets.OTPBOX_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
Add OTPBOX_KEY under repo Settings → Secrets and variables → Actions so it's injected as an env var and never checked into the workflow file.
Inside the test, create a real inbox, fill the signup form with its address, then block on the code:
import { test, expect } from '@playwright/test';
import { OTPBox } from 'otpbox-sdk';
test('sign up with a real OTP', async ({ page }) => {
const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
const inbox = await client.createInbox();
await page.goto('https://your-app.example.com/signup');
await page.fill('[name="email"]', inbox.address);
await page.click('button[type="submit"]');
const code = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
await page.fill('[name="otp"]', code);
await page.click('button[type="submit"]');
await expect(page.locator('text=Welcome')).toBeVisible();
await client.deleteInbox(inbox.id);
});
A few CI-specific gotchas: never hardcode the key in the workflow YAML or commit it to the repo — always read it from a secret as above. Watch the free tier's 200 requests/month quota if the suite runs on every push or in a matrix — each inbox create/poll/delete counts against it, so a chatty suite on a busy repo can burn through it fast; upgrade or dedicate a key to CI if that happens. Call deleteInbox() in a finally /after-hook so failed runs don't leave orphaned inboxes around — though if you forget, the 15-minute expiry cron cleans them up automatically either way.
CI/CD: OTPs in GitLab CI
Same idea, run as a GitLab CI job: use the Playwright Docker image so playwright install isn't needed, inject the key as a masked CI/CD variable, and run the suite.
# .gitlab-ci.yml
e2e:
stage: test
image: mcr.microsoft.com/playwright:v1.48.0-jammy
variables:
OTPBOX_KEY: $OTPBOX_KEY
script:
- npm ci
- npx playwright test
Add OTPBOX_KEY under your project's Settings → CI/CD → Variables, marked Masked and Protected so it never appears in job logs and only runs on protected branches.
The test code is identical to the Playwright example above: create an inbox with createInbox(), fill the signup form with inbox.address, block on waitForOtp(), then deleteInbox() in a finally block. The same gotchas apply — never print the key, watch the monthly quota on a busy pipeline, and let the 15-minute expiry cron clean up anything a failed job leaves behind.
CI/CD: OTPs in Jenkins
A declarative Jenkinsfile stage that installs Playwright's browsers and runs the suite, with the key pulled from Jenkins' own credential store rather than an env var set in the job config:
// Jenkinsfile
pipeline {
agent any
environment {
OTPBOX_KEY = credentials('otpbox-key')
}
stages {
stage('E2E') {
steps {
sh 'npm ci'
sh 'npx playwright install --with-deps chromium'
sh 'npx playwright test'
}
}
}
}
Add otpbox-key under Manage Jenkins → Credentials as a Secret text credential — that's what credentials('otpbox-key') above resolves and masks in the console log.
Same test code as the GitHub Actions example: create an inbox, drive the signup form with its address, wait for the code, delete the inbox when done. Same gotchas too — nothing Jenkins-specific changes about the quota or cleanup story.
CI/CD: OTPs in CircleCI
A single job using the Playwright Docker image, with the key set as a project environment variable (CircleCI injects those into every job automatically, no explicit wiring needed):
# .circleci/config.yml
version: 2.1
jobs:
e2e:
docker:
- image: mcr.microsoft.com/playwright:v1.48.0-jammy
steps:
- checkout
- run: npm ci
- run: npx playwright test
workflows:
test:
jobs:
- e2e
Add OTPBOX_KEY under Project Settings → Environment Variables — it's then available as process.env.OTPBOX_KEY inside the test the same way it is locally, no environment: block in the config needed.
Test code, quota, and cleanup are exactly as described above: create the inbox, run the app's signup flow against its address, wait for the code, delete the inbox afterward.
MCP (for AI agents)
The same key also works as an MCP server for agent/coding-assistant clients (Claude, Cursor, and others) that speak MCP. It is a remote (streamable HTTP) server at https://otpbox.io/mcp; authenticate with the same Authorization: Bearer header as the REST API:
{
"mcpServers": {
"otpbox": {
"url": "https://otpbox.io/mcp",
"headers": { "Authorization": "Bearer <your-key>" }
}
}
}
Exposes 13 tools — the same underlying data as /api/v1, callable directly by an agent mid-task. Inbox and message tools take the inbox id returned by create_test_inbox.
| Tool | Arguments | What it does |
|---|---|---|
create_test_inbox | domain?, local? | Create a disposable inbox; returns its id, address and expiry. |
wait_for_email | inboxId, timeoutSeconds? (1–25, default 20) | Block until a new message arrives in the inbox or the timeout elapses ({ timedOut, message }). Prefer this over polling. |
get_otp | inboxId | The most recently extracted one-time code in the inbox, if any. |
get_verification_link | inboxId | The most recently extracted confirmation/verification link (url, host, type), if any. |
get_latest_email | inboxId | The full most recent message: sender, subject, text/HTML, code, link and attachments. |
search_emails | inboxId, query? | List messages newest first (up to 50), optionally filtered by a substring match on sender or subject. |
delete_inbox | inboxId | Permanently delete an inbox and every message in it. |
create_batch | count (1–50), ttlHours? | Create several inboxes in one call; each item has its own address, token and expiry. |
create_test_identity | template, ttlHours? | Create a synthetic persona (name, email, country, profile) backed by a real inbox. Templates: us_customer, indian_customer, european_customer, business_customer, student, employee. |
create_bulk_test_identities | template, count (1–50), ttlHours? | Create several test identities in one call. |
delete_test_identity | identityId | Delete a test identity and its backing inbox. |
register_webhook | url (https), events | Register a webhook. Returns the signing secret, shown once. Accepts the inbox, message, link and identity events (not the two usage.* events — register those over REST). |
get_usage | none | Same as GET /api/v1/usage: plan, quotaLimit, used this month, period, status and pooled (true when the quota is shared across an organization). |
Each tool call counts as one request against your monthly quota, and tools enforce the same key scopes as REST. Failures come back as MCP tool errors (for example an over-quota call or a rate-limited call) rather than HTTP status codes, and a key that is missing, invalid, expired or inactive is rejected with 401 / 402 before any tool runs. Separately from the monthly quota, an agent is limited to 60 tool calls per minute per key.
Key scopes
A key can be restricted to a set of scopes when it is created in the organization dashboard (along with an optional key type, label and expiry date). A key created without scopes — including every personal free key — can use everything. A scoped key that calls something outside its scopes gets 403 { "error": "insufficient_scope", "required": "<scope>" }.
| Scope | Allows |
|---|---|
inbox:create | POST /api/v1/inboxes; MCP create_test_inbox |
inbox:read | GET /api/v1/inboxes/:id |
inbox:delete | DELETE /api/v1/inboxes/:id; MCP delete_inbox, delete_test_identity |
message:read | GET /api/v1/inboxes/:id/messages, GET /api/v1/messages/:id; MCP wait_for_email, get_latest_email, search_emails |
otp:read | MCP get_otp, get_verification_link |
identity:create | MCP create_test_identity, create_bulk_test_identities |
bulk:create | POST /api/v1/batches; MCP create_batch |
webhook:manage | GET / POST / DELETE /api/v1/webhooks; MCP register_webhook |
GET /api/v1/usage, GET /api/v1/metrics, GET /api/v1/batches/:id and the /api/v1/identities routes are not gated by a scope. Keys can also carry an expiry: an expired key gets 401 key_expired.
Rate limits and quota
Metered by calendar month (UTC), not a rolling window. Every authenticated /api/v1 request and every MCP tool call counts as one request — including reads, GET /api/v1/usage itself, and calls that end in an error such as 404. Organization keys share their organization's pooled quota (see Plans); a personal free key has its own. Exceeding your quota returns 429 with { "error": "quota_exceeded", "used": …, "limit": … } — nothing is ever charged for overage; upgrade or wait for the month to roll over. There's no CAPTCHA on /api/v1 or /mcp — the quota itself is the abuse control.
Check where you stand at any time:
GET /api/v1/usage
→ { "plan": "api_5k_monthly", "quotaLimit": 5000, "used": 812, "period": "2026-09", "status": "active", "pooled": true }
| Field | Meaning |
|---|---|
plan | free, api_5k_monthly (Pro) or enterprise. For an organization key this is the organization's plan. |
quotaLimit | Requests allowed this period, or null when there is no fixed limit. |
used | Requests used so far this period. For an organization key this is the whole organization's pooled count. |
period | The calendar month, as YYYY-MM (UTC). Counters reset when it rolls over. |
status | The key's status (active for a working key). |
pooled | true when the quota is shared across an organization's keys. |
To be told before you hit the wall, subscribe a webhook to usage.approaching (80%) and usage.exceeded (100%). Two other limits are separate from the monthly quota: minting keys is limited to 5 per day per IP (429 rate_limited), and MCP is limited to 60 tool calls per minute per key.
Errors
Errors are JSON with an error code and an appropriate HTTP status; some carry extra fields. Authentication errors (401/402) are returned before anything is counted against your quota.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_count | count must be 1–50 (max is returned). |
| 400 | bad_template | Unknown identity template (validTemplates is returned). |
| 400 | bad_domain | The requested domain is not an active domain. |
| 400 | bad_url / bad_events | Webhook url is not https://, or no valid event names were given (validEvents is returned). |
| 400 | invalid_idempotency_key | The Idempotency-Key header is malformed — see Idempotency keys. |
| 401 | missing_key | No Authorization: Bearer header. |
| 401 | invalid_key | The key is not recognized. |
| 401 | key_expired | The key had an expiry date and it has passed. |
| 402 | license_inactive | The key has been revoked or is otherwise not active. |
| 403 | insufficient_scope | The key's scopes don't include what this call needs (required names the scope). |
| 404 | not_found | No such inbox, message, batch, identity or webhook — or it belongs to a different key. |
| 409 | address_unavailable | The requested local part is taken, or no address could be allocated for an identity or batch item. |
| 409 | try_again | A random address couldn't be allocated just now; retry. |
| 409 | idempotency_key_in_progress | A request with the same Idempotency-Key is still running. |
| 429 | quota_exceeded | Monthly quota used up (used and limit are returned) — see Rate limits and quota. |
| 429 | rate_limited | Too many key-mint requests from your IP today (retryAfter, in seconds, is returned). |
| 503 | no_domains | No mailbox domain is currently available; retry shortly. |
Need higher volume or an SLA?
Running a large test matrix, or want a dedicated plan with priority support? Tell us about your use case and we'll follow up. Current uptime: status page.
Questions: abuse@otpbox.io.
See also: guides (Playwright, Cypress, AI agents), pricing and the changelog.