Receiving OTP Codes Programmatically in Node.js

Automating a flow that requires an emailed one-time passcode means solving three problems: getting an address that receives mail, knowing when the mail has arrived, and pulling the code out reliably. Here is how to do each in Node.js, and why the naive version of each is the source of most flakiness.

The problem

You are automating something — an integration test, a QA sweep, an onboarding script — and the flow stops at a form asking for a six-digit code that was just emailed. To continue, your program needs to read that email.

The naive approach is to point an IMAP client at a real mailbox, sleep for fifteen seconds, fetch the newest message, and regex the body for digits. Every part of that is a problem: shared mailboxes race between parallel runs, fixed sleeps are simultaneously too slow and too short, "the newest message" is not necessarily yours, and a naive digit regex will happily return the year from a copyright footer.

Step 1: create an inbox per run

One address per run, created at the start and destroyed at the end. This removes the entire class of bug where two concurrent runs read each other's codes.

// mailbox.js
const BASE = 'https://tempmailgrab.com/api/v1';
const KEY = process.env.TMG_API_KEY;

const headers = {
  Authorization: 'Bearer ' + KEY,
  'Content-Type': 'application/json',
};

export async function createInbox() {
  const res = await fetch(BASE + '/inbox/generate', {
    method: 'POST',
    headers,
    // Keep the TTL close to the length of the run. A short-lived address that
    // expires on its own is one less thing to clean up if the process crashes.
    body: JSON.stringify({ ttl_seconds: 1800 }),
  });
  if (!res.ok) throw new Error('createInbox: ' + res.status + ' ' + (await res.text()));
  return res.json(); // { id, address, expires_at }
}

export async function destroyInbox(id) {
  await fetch(BASE + '/inbox/' + id, { method: 'DELETE', headers });
}

Step 2: poll, do not sleep

A fixed sleep guesses at delivery latency. Guess low and the test is flaky; guess high and every run pays the worst case. Polling with a deadline gets you the best of both: it returns as soon as the message lands, and fails with a clear error if it never does.

export async function waitForOtp(inboxId, { timeoutMs = 60_000, intervalMs = 2_000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let lastSeen = 0;

  while (Date.now() < deadline) {
    const res = await fetch(BASE + '/inbox/' + inboxId + '/messages', { headers });

    if (res.ok) {
      const { messages } = await res.json();
      lastSeen = messages.length;

      // extracted_otp is parsed server-side on arrival. Prefer it over your own
      // regex: it scores candidates by proximity to words like "code",
      // "verification" and "passcode" rather than taking the first digits it finds.
      const withCode = messages.find((m) => m.extracted_otp);
      if (withCode) return withCode.extracted_otp;
    }

    await new Promise((r) => setTimeout(r, intervalMs));
  }

  throw new Error(
    'No OTP within ' + timeoutMs + 'ms (' + lastSeen + ' message(s) arrived, none contained a code)',
  );
}

The error message distinguishes two very different situations. "0 messages arrived" is a delivery problem. "3 messages arrived, none contained a code" is a parsing problem, or a sign the code is in an image or an attachment. Reporting them identically means starting every debug from scratch.

Step 3: put it together

// verify.js
import { createInbox, waitForOtp, destroyInbox } from './mailbox.js';

const inbox = await createInbox();
console.log('Using', inbox.address);

try {
  await fetch('https://api.example.com/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: inbox.address }),
  });

  const code = await waitForOtp(inbox.id);
  console.log('Received code', code);

  const verify = await fetch('https://api.example.com/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: inbox.address, code }),
  });

  if (!verify.ok) throw new Error('Verification rejected: ' + verify.status);
  console.log('Verified.');
} finally {
  await destroyInbox(inbox.id);
}

Why not roll your own regex?

Because the obvious one is wrong in ways you will not notice until it fails in CI at 3am. Consider a message containing:

Your verification code is 481920.
This code expires in 10 minutes.
Order #55210 · (c) 2026 Example Inc · 1600 Amphitheatre Parkway

A pattern like /\d{4,8}/ matches 481920, 55210 and 1600 — and returns whichever appears first, which depends on template changes you do not control. Add a plain-text footer with a phone number and it gets worse.

Reliable extraction weights candidates by their distance from words like code, OTP, verification and passcode, prefers the conventional 4–8 digit lengths, and discards matches that sit inside a longer number. That logic runs server-side when the message arrives, and the result is on the message as extracted_otp. Using it is one field access instead of a regex you will be maintaining forever.

When you need the full message

The list endpoint returns metadata and a short snippet. For magic links, or to assert on the body, fetch the individual message:

const res = await fetch(BASE + '/inbox/' + inboxId + '/messages/' + messageId, { headers });
const full = await res.json();

// full.text_body        plain-text body
// full.html_body        sanitised HTML
// full.extracted_otp    the code, if any
// full.extracted_links  every URL found in the message

const magicLink = full.extracted_links.find((u) => u.includes('/auth/callback'));

extracted_links is worth knowing about. Parsing anchors out of HTML yourself means handling quoted-printable soft breaks, tracking-redirect wrappers, and entity encoding — the same category of quiet, occasional breakage as the digit regex.

Webhooks instead of polling

Polling is right for scripts and tests. For a long-running service, register a webhook and let delivery push to you:

await fetch(BASE + '/inbox/' + inbox.id + '/webhook', {
  method: 'POST',
  headers,
  body: JSON.stringify({ url: 'https://your-service.example.com/hooks/mail' }),
});

Your endpoint receives the message as it arrives, with the same extracted fields. Verify the signature on receipt and treat the payload as untrusted input like any other inbound webhook.

Common mistakes

MistakeWhy it hurtsInstead
Reusing one address across runsParallel runs read each other's codesOne inbox per run
Fixed sleep()Slow on good days, flaky on bad onesPoll with a deadline
Taking the newest message blindlyGrabs a welcome email instead of the codeMatch on subject or sender
Hand-rolled digit regexMatches years, order numbers, ZIP codesextracted_otp
No cleanup on failureLeaked inboxes accumulatetry/finally
Identical error for every failureEvery debug starts from zeroDistinguish delivery from parsing

A note on scope

This is for systems you own or are authorised to test. Automating OTP retrieval against a third-party service you do not control is likely to breach its terms of service, and disposable addresses are a poor choice for any account you actually need to keep — if the inbox expires, so does your ability to recover the account. The temp mail versus alias comparison covers where each belongs.

Endpoint details are in the API reference.