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.

PlanRequests / monthPriceHow to get it
Personal free key200 per key$0POST /api/v1/keys/free, no account
Organization — Free200, pooled$0Sign up, create an organization, mint keys per project
Organization — Pro5,000, pooled$9 / monthDashboard → your organization → Plan & usage → Upgrade to Pro
EnterpriseCustomCustomTell 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.

MethodPathPurpose
POST/api/v1/keys/freeMint a free key (no auth; 5/day/IP). Returns { id, key, plan, quotaLimit }.
POST/api/v1/keys/sandboxMint a sandbox key (no auth; 5/day/IP).
POST/api/v1/inboxesCreate an inbox. Body: { domain?, local? }. Returns 201 { id, address, domain, createdAt, expiresAt, token }. Scope inbox:create. Accepts an Idempotency-Key.
GET/api/v1/inboxes/:idInbox metadata: { id, address, domain, createdAt, expiresAt }. Only readable by the key that created it. Scope inbox:read.
DELETE/api/v1/inboxes/:idDelete the inbox and every message in it immediately. Returns { ok: true }. Scope inbox:delete.
GET/api/v1/inboxes/:id/messagesList 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/:idFull 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/usagePlan, limit and usage this month — see Rate limits and quota.
POST/api/v1/batchesCreate up to 50 inboxes in one call — see Batches. Scope bulk:create.
GET/api/v1/batches/:idA batch's status and per-item results.
POST/api/v1/identitiesCreate a test identity: a synthetic persona backed by a real inbox.
POST/api/v1/identities/bulkCreate up to 50 test identities in one call.
GET/api/v1/identities/:idFetch one test identity (the inbox token is never re-shown).
DELETE/api/v1/identities/:idDelete an identity and its backing inbox.
GET/api/v1/webhooksList this key's webhooks. Scope webhook:manage.
POST/api/v1/webhooksRegister a webhook. Scope webhook:manage.
DELETE/api/v1/webhooks/:idDelete a webhook. Scope webhook:manage.
GET/api/v1/metricsCall 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" }
  ]
}
  • count is required, 1–50; anything else returns 400 { "error": "bad_count", "max": 50 }. ttlHours is optional and capped at the server maximum (currently 3 hours); without it, the default inbox lifetime applies.
  • Batch status is completed (every item succeeded), partial (some failed) or failed (none succeeded). A completed item's id is the inbox id, so you can read it with GET /api/v1/inboxes/:id/messages. (The example above is abbreviated to two items.)
  • Each item's token is the inbox's own bearer token. It is only returned by this create call.
  • GET /api/v1/batches/:id returns { id, status, requestedCount, createdAt, completedAt, items: [{ id, status, address, expiresAt, error }] } (no tokens); id is null for a failed item. Unknown or someone else's batch: 404 not_found.
  • Requires the bulk:create scope and accepts an Idempotency-Key. 503 no_domains if 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 email in the form under test. token is the backing inbox's bearer token, shown only in this response; use it with the public inbox API (GET /api/inboxes/me/messages with Authorization: Bearer <token>) to read what arrives.
  • The persona's profile depends on the template: business_customer adds company and role, student adds university and major, employee adds company, department and jobTitle; the customer templates return an empty profile.
  • ttlHours is optional and capped at the server maximum (currently 3 hours). Errors: 400 bad_template (with validTemplates), 409 address_unavailable, 503 no_domains.
  • POST /api/v1/identities/bulk with { "template", "count", "ttlHours"? } (count 1–50, else 400 bad_count) returns 201 { batchId, template, requestedCount, items: [...] }; batchId looks like idbatch_.... Each item is { id, status: "active", name, email, country, profile, token, expiresAt }, or { id, status: "failed", error }.
  • GET /api/v1/identities/:id returns the identity without its token, plus batchId and error. DELETE /api/v1/identities/:id deletes the identity and its backing inbox and returns { ok: true, id, inboxDeleted }. Unknown ids return 404 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 returns 400 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 /inboxes and /batches without 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
}
  • url must start with https://, otherwise 400 { "error": "bad_url" }.
  • events is a list from the catalog below. Unknown names are ignored; if none are valid you get 400 { "error": "bad_events", "validEvents": [...] }.
  • The secret is returned once, only in this response. Store it now — you need it to verify signatures and it cannot be retrieved later.
  • GET /api/v1/webhooks returns { webhooks: [{ id, url, events, status, createdAt }] } (never the secret). status is active or disabled.
  • DELETE /api/v1/webhooks/:id returns { ok: true }, or 404 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.

EventFires when
inbox.createdAn inbox is created with POST /api/v1/inboxes or the MCP create_test_inbox tool. Not fired for batch or identity inboxes.
inbox.deletedAn inbox is deleted with DELETE /api/v1/inboxes/:id or the MCP delete_inbox tool.
inbox.expiredOne of your inboxes reaches its expiry and is removed by the cleanup job (which runs every 15 minutes).
message.receivedAn email arrives in one of your inboxes.
otp.extractedAn arriving email contained a one-time code. Same payload as message.received.
link.detectedAn arriving email contained a confirmation or other link.
identity.createdA test identity is created. A bulk create fires one event for the whole batch, with a batch-shaped payload.
identity.deletedA test identity is deleted.
usage.approachingUsage first reaches 80% of the monthly limit. Once per billing period.
usage.exceededUsage 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 in GET /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 with POST /api/v1/webhooks. The dashboard also sends a webhook.test event 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.

ToolArgumentsWhat it does
create_test_inboxdomain?, local?Create a disposable inbox; returns its id, address and expiry.
wait_for_emailinboxId, 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_otpinboxIdThe most recently extracted one-time code in the inbox, if any.
get_verification_linkinboxIdThe most recently extracted confirmation/verification link (url, host, type), if any.
get_latest_emailinboxIdThe full most recent message: sender, subject, text/HTML, code, link and attachments.
search_emailsinboxId, query?List messages newest first (up to 50), optionally filtered by a substring match on sender or subject.
delete_inboxinboxIdPermanently delete an inbox and every message in it.
create_batchcount (1–50), ttlHours?Create several inboxes in one call; each item has its own address, token and expiry.
create_test_identitytemplate, 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_identitiestemplate, count (1–50), ttlHours?Create several test identities in one call.
delete_test_identityidentityIdDelete a test identity and its backing inbox.
register_webhookurl (https), eventsRegister 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_usagenoneSame 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>" }.

ScopeAllows
inbox:createPOST /api/v1/inboxes; MCP create_test_inbox
inbox:readGET /api/v1/inboxes/:id
inbox:deleteDELETE /api/v1/inboxes/:id; MCP delete_inbox, delete_test_identity
message:readGET /api/v1/inboxes/:id/messages, GET /api/v1/messages/:id; MCP wait_for_email, get_latest_email, search_emails
otp:readMCP get_otp, get_verification_link
identity:createMCP create_test_identity, create_bulk_test_identities
bulk:createPOST /api/v1/batches; MCP create_batch
webhook:manageGET / 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 }
FieldMeaning
planfree, api_5k_monthly (Pro) or enterprise. For an organization key this is the organization's plan.
quotaLimitRequests allowed this period, or null when there is no fixed limit.
usedRequests used so far this period. For an organization key this is the whole organization's pooled count.
periodThe calendar month, as YYYY-MM (UTC). Counters reset when it rolls over.
statusThe key's status (active for a working key).
pooledtrue 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.

StatusCodeMeaning
400bad_countcount must be 1–50 (max is returned).
400bad_templateUnknown identity template (validTemplates is returned).
400bad_domainThe requested domain is not an active domain.
400bad_url / bad_eventsWebhook url is not https://, or no valid event names were given (validEvents is returned).
400invalid_idempotency_keyThe Idempotency-Key header is malformed — see Idempotency keys.
401missing_keyNo Authorization: Bearer header.
401invalid_keyThe key is not recognized.
401key_expiredThe key had an expiry date and it has passed.
402license_inactiveThe key has been revoked or is otherwise not active.
403insufficient_scopeThe key's scopes don't include what this call needs (required names the scope).
404not_foundNo such inbox, message, batch, identity or webhook — or it belongs to a different key.
409address_unavailableThe requested local part is taken, or no address could be allocated for an identity or batch item.
409try_againA random address couldn't be allocated just now; retry.
409idempotency_key_in_progressA request with the same Idempotency-Key is still running.
429quota_exceededMonthly quota used up (used and limit are returned) — see Rate limits and quota.
429rate_limitedToo many key-mint requests from your IP today (retryAfter, in seconds, is returned).
503no_domainsNo 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.

← Back to OTPBox