If you've ever written an end-to-end test that covers an email verification
flow, you know the pain. You call your sign-up endpoint, an email goes out,
and then... you poll. You sleep 2 seconds, check the inbox, sleep again,
check again, pray the CI server isn't slow today.
It works. It's also fragile, slow, and annoying to maintain.
This post covers a better approach: using a disposable inbox API
(FreeCustom.email) that gives
you a real inbox per test run, automatic OTP extraction, and a WebSocket
connection that pushes the email to you the moment it arrives — no polling,
no sleeps, no flakiness.
The problem with polling
The standard approach looks like this:
await signUp({ email: 'test123@mailinator.com', password: 'hunter2' });
// 🤢 please don't do this
let otp = null;
for (let i = 0; i < 10; i++) {
await sleep(2000);
const messages = await checkMailinator('test123');
if (messages.length > 0) {
otp = extractOtp(messages[0].body); // regex hell
break;
}
}
Problems:
- Flaky — your regex breaks when the email template changes
- Slow — you're waiting up to 20 seconds for something that arrived in 300ms
- Shared inboxes — Mailinator's public inboxes mean test data from other runs can bleed in
- No isolation — parallel test runs interfere with each other
A better flow
Here's the same test using the FreeCustom.email API:
const BASE = 'https://api.freecustom.email/v1';
const HEADERS = { Authorization: `Bearer ${process.env.FCE_API_KEY}` };
// 1. Register a unique inbox for this test run
const inbox = `test-${crypto.randomUUID()}@ditube.info`;
await fetch(`${BASE}/inboxes`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ inbox }),
});
// 2. Trigger your sign-up flow
await signUp({ email: inbox, password: 'hunter2' });
// 3. Wait for the OTP — with a real timeout, not a sleep loop
const otp = await waitForOtp(inbox);
// 4. Complete verification
await verifyAccount({ email: inbox, otp });
// 5. Clean up
await fetch(`${BASE}/inboxes/${inbox}`, { method: 'DELETE', headers: HEADERS });
The waitForOtp function uses the WebSocket endpoint:
async function waitForOtp(inbox, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(
`wss://api.freecustom.email/v1/ws?api_key=${process.env.FCE_API_KEY}&mailbox=${inbox}`
);
const timer = setTimeout(() => {
ws.close();
reject(new Error('OTP timeout'));
}, timeoutMs);
ws.on('message', (data) => {
const event = JSON.parse(data);
if (event.type === 'connected') return; // welcome frame
if (event.otp) {
clearTimeout(timer);
ws.close();
resolve(event.otp);
}
});
ws.on('error', (err) => {
clearTimeout(timer);
reject(err);
});
});
}
The email arrives, Redis pub/sub fires, WebSocket pushes it to your client,
OTP is already parsed. The whole round-trip from email sent to OTP in hand
is typically under 500ms.
Or just use the REST endpoint
If WebSocket feels like overkill for your use case (simple scripts, CI jobs),
there's a dedicated OTP endpoint that's much simpler:
curl https://api.freecustom.email/v1/inboxes/test@ditube.info/otp \
-H "Authorization: Bearer $FCE_API_KEY"
{
"success": true,
"otp": "482910",
"email_id": "64f1a2b3c4d5e6f7a8b9c0d1",
"from": "no-reply@yourapp.com",
"subject": "Your verification code",
"timestamp": 1710234567000,
"verification_link": "https://yourapp.com/verify?code=482910"
}
No regex. No template parsing. The OTP is just... there.
Quick API reference
| Endpoint | What it does |
|---|---|
POST /v1/inboxes |
Register an inbox |
GET /v1/inboxes |
List your registered inboxes |
DELETE /v1/inboxes/:inbox |
Unregister an inbox |
GET /v1/inboxes/:inbox/messages |
List all messages |
GET /v1/inboxes/:inbox/messages/:id |
Get a single message |
GET /v1/inboxes/:inbox/otp |
Get the latest OTP |
DELETE /v1/inboxes/:inbox/messages/:id |
Delete a message |
WSS /v1/ws?api_key=...&mailbox=... |
Real-time push |
Every response includes rate limit headers:
X-API-Plan: developer
X-RateLimit-Limit-Second: 10
X-RateLimit-Remaining-Second: 9
X-RateLimit-Limit-Month: 100000
X-RateLimit-Remaining-Month: 99847
Authentication
Keys are passed via Authorization: Bearer header or ?api_key= query param.
Keys are SHA-256 hashed on the server — the raw key is shown once at creation
and never stored. Rotating a key doesn't reset your monthly quota since usage
is tracked by account, not by key.
// Header (recommended)
Authorization: Bearer fce_abc123...
// Query param (useful for WebSocket connections)
?api_key=fce_abc123...
Plans
| Plan | Price | Req/mo | OTP | WebSocket |
|---|---|---|---|---|
| Free | Free | 5,000 | ❌ | ❌ |
| Developer | $7/mo | 100,000 | ✅ | ❌ |
| Startup | $19/mo | 500,000 | ✅ | ✅ |
| Growth | $49/mo | 2,000,000 | ✅ | ✅ + custom domains |
| Enterprise | $149/mo | 10,000,000 | ✅ | ✅ |
You can also buy non-expiring request credits ($10 → 200k requests) as a
top-up on any plan.
Try it
Interactive playground (no sign-up needed to explore):
https://www.freecustom.email/api/playground
Free tier is genuinely free — 5,000 requests/month, no credit card.
I'm the developer — happy to answer questions in the comments about the
architecture, how OTP parsing works, or anything else. Would also love
to know: what does your current verification testing flow look like?
What's the most painful part?
Top comments (0)