otpbox

OTPBox / Guides / Inbox pools

Enterprise · Bulk provisioning

How to provision a 10,000-inbox QA pool

A per-test inbox is the right default: create it, use it, let it expire. But a seeded QA environment, a load test or a signup regression that runs for weeks wants something else — thousands of addresses that already exist, stay put, and follow a pattern you can predict. This guide walks through building one with OTPBox's bulk provisioning API: 10,000 numbered inboxes, created in the background in about a minute, read by your tests with any key in the project.

When a pool beats per-test inboxes

Per-test inboxBatchPool
How many per call1Up to 50Up to 50,000 per job
How long they live1 hour (the default lifetime)Up to 3 hours12h, 90d or until released, within your agreement
CreatedIn the responseIn the responseIn the background (10,000 in about a minute)
AddressesRandomRandomNumbered, random tail or prefix
PlanEvery planEvery planEnterprise

If each test can create its own inbox and throw it away, keep doing that — see the Playwright guide. Reach for a pool when the addresses have to exist before the tests run (seeded accounts, fixtures checked into a repo, a staging database pre-loaded with users) or when you need more than a batch of 50 at once.

1. Enable bulk provisioning

Bulk provisioning is part of Enterprise plans. Your agreement sets the largest pool you can hold, the longest lifetime a pool may have, the inbox-day price (and any monthly allowance), and optional message-retention caps. Once it is enabled for your organization:

Check what your agreement allows before you start:

curl https://otpbox.io/api/v1/provisioning/usage \
  -H "authorization: Bearer $OTPBOX_KEY"

The response carries your pool size, your limits, the retention settings, and this cycle's usage (more on that in step 6).

2. Create the job

One request asks for the whole pool. Here: 10,000 inboxes named qa-00001 to qa-10000 on qa.example.com, living 90 days.

curl -X POST https://otpbox.io/api/v1/provisioning-jobs \
  -H "authorization: Bearer $OTPBOX_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: qa-pool-2026-10" \
  -d '{ "count": 10000, "lifetime": "90d",
        "pattern": "qa-{n}", "domain": "qa.example.com" }'

202 Accepted
{
  "id": "prov_...", "status": "queued", "requestedCount": 10000, "createdCount": 0, "progress": 0,
  "domain": "qa.example.com", "pattern": "qa-{n:5}", "startAt": 1, "lifetimeHours": 2160,
  "expiresAt": 1798000000000,
  "exportUrl": "https://otpbox.io/api/v1/provisioning-jobs/prov_.../export?format=csv"
}

The job is accepted straight away; nothing has been created yet. Save the id. Sending an Idempotency-Key means a CI retry after a network blip returns this same job instead of starting a second one.

Choosing a naming pattern

lifetime is "12h", "90d" or "never" (until you release the pool), or send lifetimeHours as a number. Asking for longer than your agreement allows returns 400 lifetime_exceeds_max with maxLifetimeHours.

3. Follow it to completion

Status goes queued → running → completed (or partial / failed, with error saying why). progress runs from 0 to 1. A small poll loop for a setup script:

JOB=prov_...
while :; do
  STATUS=$(curl -s https://otpbox.io/api/v1/provisioning-jobs/$JOB \
    -H "authorization: Bearer $OTPBOX_KEY" | jq -r .status)
  echo "$STATUS"
  case "$STATUS" in queued|running) sleep 10 ;; *) break ;; esac
done

Every poll is an ordinary request against your quota, so poll slowly, or skip polling altogether: subscribe a webhook to provisioning.completed and you get { jobId, status, createdCount, failedCount, exportUrl, ... } when it finishes. The dashboard's Provisioning page shows the same progress to anyone in your organization.

One job runs at a time per organization: a second POST while this one is running returns 409 job_in_progress. Live pools across all jobs can't exceed your pool size (409 pool_limit, with limit and live).

4. Export the address list

curl -o pool.csv https://otpbox.io/api/v1/provisioning-jobs/$JOB/export \
  -H "authorization: Bearer $OTPBOX_KEY"

head -3 pool.csv
address,inbox_id,created_at,expires_at
qa-00001@qa.example.com,...,2026-10-06T09:14:02.118Z,2027-01-04T09:14:02.118Z
qa-00002@qa.example.com,...,2026-10-06T09:14:02.118Z,2027-01-04T09:14:02.118Z

Add ?format=json for { jobId, inboxes: [{ id, address, createdAt, expiresAt }] } instead. Only live inboxes are listed, and expires_at is empty for a pool created with "never". Members can also download the file from the dashboard's Provisioning page — useful for handing the list to whoever seeds the staging database.

5. Use the pool in a test

Pool inboxes belong to the project, not to the key that created them. Any key in the project can read them with the usual routes — GET /api/v1/inboxes/:id/otp, POST /api/v1/inboxes/:id/wait and the rest — so your CI key can be a different, narrowly scoped key, and rotating or revoking the key that created the pool doesn't lock you out of it.

A test picks a row, uses the address in the flow under test, and waits for the code. Because a pool inbox is reused across runs, pass since so an older code still sitting in the inbox doesn't satisfy the wait:

import { readFileSync } from 'node:fs';

// address,inbox_id,created_at,expires_at -> [{ address, id }]
const pool = readFileSync('pool.csv', 'utf8').trim().split('\n').slice(1)
  .map((line) => { const [address, id] = line.split(','); return { address, id }; });

// One inbox per parallel worker, e.g. Playwright's TEST_PARALLEL_INDEX
const inbox = pool[Number(process.env.TEST_PARALLEL_INDEX ?? 0)];

const since = Date.now();
await page.fill('[name=email]', inbox.address);
await page.click('text=Send code');

const res = await fetch(
  `https://otpbox.io/api/v1/inboxes/${inbox.id}/otp?timeout=20&since=${since}`,
  { headers: { authorization: `Bearer ${process.env.OTPBOX_KEY}` } },
);
const { code } = await res.json();   // 404 no_otp if nothing arrived in 20 s
await page.fill('[name=code]', code);

timeout holds the request open for up to 25 seconds until a code arrives, so each check is one request rather than a polling loop. Narrow the match with from or subject if the inbox receives more than one kind of mail — see Waiting for mail.

The TypeScript SDK's createProvisioningJob, waitForProvisioningJob and exportProvisioningJob helpers arrive with otpbox-sdk 0.3.0, which isn't on npm yet. Everything in this guide uses the REST API, which works today.

6. Watch inbox-days

Pools are priced per inbox-day in your agreement. An inbox-day is one pool inbox kept for one day; the live pool is measured every 15 minutes, so 10,000 inboxes held all day are 10,000 inbox-days, and half a day is about 5,000.

curl https://otpbox.io/api/v1/provisioning/usage \
  -H "authorization: Bearer $OTPBOX_KEY"

// abbreviated: the 10,000-inbox pool, four full days into a new cycle
{
  "usage": {
    "period": "2026-11-01", "resetsOn": "2026-12-01",
    "inboxDays": 40000, "allowance": null, "overage": 0, "peak": 10000,
    "daily": [ ..., { "day": "2026-11-04", "inboxDays": 10000, "peak": 10000 }, ... ]
  },
  ...
}

usage covers the current billing cycle: the inbox-days so far, your agreement's monthly allowance if it has one, any overage, the peak pool size, and a daily breakdown. The dashboard's Provisioning page shows the same numbers. If your agreement caps message retention, the limits appear under settings in this response: messages older than N days are deleted (checked every 15 minutes), and each pool inbox keeps only its newest N messages.

7. Release the pool when you're done

A 90-day pool that finished its job in three weeks is still counting inbox-days. Release it:

curl -X POST https://otpbox.io/api/v1/provisioning-jobs/$JOB/release \
  -H "authorization: Bearer $OTPBOX_KEY" \
  -H "content-type: application/json" \
  -d '{ "confirm": true }'

Every inbox in the pool stops accepting mail immediately and is removed by the cleanup job. (To stop a job that is still creating inboxes, use POST /api/v1/provisioning-jobs/:id/cancel; the inboxes made so far stay.)

Or let it run out: at the end of its lifetime the pool expires in bulk, the job becomes expired, and provisioning.expired fires once. Pool inboxes never send the per-inbox inbox.expiring or inbox.expired webhooks, so a 10,000-inbox pool doesn't flood your endpoint with 10,000 events.

Checklist

  1. Enterprise agreement with bulk provisioning; an organization project key with bulk:create.
  2. POST /api/v1/provisioning-jobs with count, lifetime, pattern and (optionally) domain.
  3. Wait for completed — poll the job or take the provisioning.completed webhook.
  4. Download /export as CSV or JSON.
  5. In tests, read codes with any project key: /otp?timeout=20&since=….
  6. Release the pool when you're done, and keep an eye on inbox-days.

Next steps

Need a pool for your QA environment? Talk to us about Enterprise

← Back to OTPBox