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.
| Per-test inbox | Batch | Pool | |
|---|---|---|---|
| How many per call | 1 | Up to 50 | Up to 50,000 per job |
| How long they live | 1 hour (the default lifetime) | Up to 3 hours | 12h, 90d or until released, within your agreement |
| Created | In the response | In the response | In the background (10,000 in about a minute) |
| Addresses | Random | Random | Numbered, random tail or prefix |
| Plan | Every plan | Every plan | Enterprise |
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.
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:
403 provisioning_requires_project_key; an organization without the feature gets 403 provisioning_not_enabled.bulk:create scope to start a job, inbox:read to export and inbox:delete to release (a key with no scope restrictions has all three).domain out and OTPBox picks one.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).
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.
qa-{n} numbers the addresses. OTPBox pads the number to fit the count, which is why the response shows qa-{n:5}. Set the width yourself with {n:W}, and the first number with startAt (default 1) — handy for a second pool that continues at qa-10001.qa-{random} adds 8 random characters instead, and "prefix": "qa-" is shorthand for the same thing.a-z0-9._-, and the longest address it can produce must fit in 32 characters.{n}, a number already in use (by an earlier pool, say) is skipped and counted in failedCount; the job ends partial with error: "address_taken".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.
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).
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.
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.
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.
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.
bulk:create.POST /api/v1/provisioning-jobs with count, lifetime, pattern and (optionally) domain.completed — poll the job or take the provisioning.completed webhook./export as CSV or JSON./otp?timeout=20&since=….Need a pool for your QA environment? Talk to us about Enterprise
← Back to OTPBox