DEV Community

Dishant Singh
Dishant Singh

Posted on

Stop Polling for OTPs — Use a Disposable Inbox API with Real-Time WebSocket Push

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
{
  "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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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)