"Email verification" isn't one flow, it's several. Signup OTP is the common case, but a login can also demand a second-factor code, a passwordless login can hand you a clickable link instead of a code, and a "remember this device" checkbox can change whether the second factor fires at all. All three are testable with the same real disposable inbox — the difference is what you do with the message once it arrives, and when you trigger it.
The shape is identical to signup OTP — create an inbox, use its address as the account's email, wait for a code — except the trigger is a login attempt on an already-registered account, not a signup form. The account has to exist first with that inbox's address as its email, so most 2FA tests are really two steps: register (or seed) the account, then log in and clear the 2FA prompt.
import { OTPBox } from 'otpbox-sdk';
const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY });
const inbox = await client.createInbox();
// 1. Register the account with inbox.address (own signup flow, own OTP step
// if signup itself requires verification - see the Playwright guide)
// 2. Trigger login, which fires a fresh 2FA email
await fetch('https://your-app.example.com/api/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: inbox.address, password: 'Testpass123!' }),
});
const code = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
if (!code) throw new Error('no 2FA code arrived');
await fetch('https://your-app.example.com/api/login/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: inbox.address, code }),
});
The one gotcha specific to 2FA: waitForOtp() only considers messages received after the call started, so if signup already sent one code and login sends a second, waiting after the login call (not before) is what keeps you from reading the stale signup code.
Here the email contains a link, not a code, so the test navigates a browser straight to the extracted URL instead of typing anything. The full message response classifies the link's purpose via linkType (and the equivalent field on the message-list endpoint, linkHost/linkType) — one of verification, password_reset, magic_login, unsubscribe, tracking or general. A passwordless login email should classify as magic_login:
GET /api/v1/messages/:id
→ {
"id": "msg_...",
"from": "noreply@your-app.example.com",
"subject": "Your login link",
"text": "Click to sign in: https://your-app.example.com/auth/magic?t=...",
"code": null,
"link": { "url": "https://your-app.example.com/auth/magic?t=...", "host": "your-app.example.com", "type": "magic_login" },
...
}
With otpbox-sdk, waitForEmail() returns the message summary; fetch the full message to read link.url:
const message = await client.waitForEmail(inbox.id, { timeoutMs: 20_000 });
const full = await client.getMessage(message.id);
if (full.link?.type !== 'magic_login') throw new Error(`unexpected link type: ${full.link?.type}`);
Then, in whichever browser tool is driving the test, navigate directly to that URL instead of filling in a code field:
| Tool | Call |
|---|---|
| Playwright | await page.goto(full.link.url) |
| Selenium | driver.get(full["link"]["url"]) |
| Puppeteer | await page.goto(full.link.url) |
Since the link itself carries the auth token, there's no code field to fill and no submit button to click — the navigation is the login. Assert on whatever the app shows post-login (dashboard, welcome text, a session cookie) the same way you would after a code-based flow.
A common feature next to email 2FA is a "remember this device for 30 days" checkbox, and the thing worth testing is the negative case: a second login from the same device (or the same cookie/token the app uses to recognize it) should not send another 2FA email. OTPBox makes that assertion straightforward because you control the inbox: log in twice against the same address, and check that only one 2FA message ever arrived.
const inbox = await client.createInbox();
// ...register account, log in once with "remember this device" checked,
// clear that first 2FA email...
// Second login, same session/cookie jar that "remembers" the device
await loginAgain(inbox.address);
// Give the app a moment, then assert nothing new arrived
const secondCheck = await client.waitForEmail(inbox.id, { timeoutMs: 5_000 });
if (secondCheck) throw new Error('2FA re-triggered on a remembered device');
A short timeout is appropriate here since you're asserting an absence, not waiting for something you expect — waitForEmail()/waitForOtp() returning null after the timeout is the passing case. Pair this with the positive case (a login from a fresh session does re-trigger 2FA) using a second inbox, so the test suite covers both directions of the "remember this device" logic.
Putting the login-2FA case together as a runnable test, including the account setup step:
import { test, expect } from '@playwright/test';
import { OTPBox } from 'otpbox-sdk';
test('login requires a real 2FA code', async ({ page }) => {
const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
const inbox = await client.createInbox();
try {
// Seed the account (own app-specific signup, own OTP step if needed)
await page.goto('https://your-app.example.com/signup');
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', 'Testpass123!');
await page.click('button[type="submit"]');
const signupCode = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
await page.fill('[name="otp"]', signupCode!);
await page.click('button[type="submit"]');
// Log out, then log back in to trigger 2FA
await page.click('text=Log out');
await page.goto('https://your-app.example.com/login');
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', 'Testpass123!');
await page.click('button[type="submit"]');
const loginCode = await client.waitForOtp(inbox.id, { timeoutMs: 20_000 });
expect(loginCode).toBeTruthy();
await page.fill('[name="otp"]', loginCode!);
await page.click('button[type="submit"]');
await expect(page.locator('text=Dashboard')).toBeVisible();
} finally {
await client.deleteInbox(inbox.id);
}
});
Polling with waitForOtp()/waitForEmail() is the simplest option for a test suite, but if your setup can receive inbound HTTP, subscribing a webhook to otp.extracted and link.detected gets you the event the moment it happens rather than on the next poll tick — see Webhooks for the full event catalog and payload shapes.
Ready to try it against your own app? Create a free account
← Back to OTPBox