How to Test OTP Flows in 2026: A Complete Guide
End-to-end guide to testing one-time-password flows in Node.js, Python, Go, and curl. With Playwright and Cypress integrations.
OTP-based signup is the dominant pattern for new account creation in 2026. Almost every consumer-facing app uses email or SMS verification, and a growing share of B2B apps use email-based magic links instead of passwords. Which means almost every test suite needs to handle OTP flows — and most of them do it badly.
This is a complete guide to testing OTP flows in 2026: why it matters, the four anti-patterns to avoid, the right primitive to reach for, code samples in four languages, framework-specific integrations, CI considerations, and a troubleshooting checklist.
Why OTP testing matters
Skipping OTP coverage in your test suite is one of the most common ways production bugs slip through. The verification step is where transactional providers fail (rate limits, blocked DKIM), where regex-driven OTP extraction breaks (a copy change shifts the format), and where timing assumptions fall apart (the email takes four seconds, your test waits three).
If you do not test the OTP step, you cannot test the signup, and if you cannot test the signup, you cannot test anything that requires being logged in. The failure mode is “the entire authenticated surface area is untested.” That is the bug we see most.
The four anti-patterns
Almost every test suite that handles OTPs badly is doing one of these four things.
Anti-pattern 1: sleep(10) and hope
await page.click('button[name=signup]');
await page.waitForTimeout(10000); // hope the email arrived
const code = await fetchTestInbox();
Slow, flaky, and silently sleeps when the email arrives in 200ms. Doubly silent when the email arrives in 10.5 seconds and the test fails for unrelated reasons.
Anti-pattern 2: send through a real provider in CI
// Triggers a real SMS send through Twilio on every CI run
await page.click('button[name=signup-with-sms]');
This works, mostly. It also costs you $0.01-0.05 per test run, and a parallel CI suite that runs hourly can quietly burn $300-1,500 a month. Worse: you are now triggering real production webhooks on every test run.
Anti-pattern 3: mock the OTP step entirely
mockApi.intercept('/api/verify-otp').respond({ success: true });
Now you are not testing the integration with your transactional provider, your DKIM setup, your OTP regex, your rate-limit logic, or any of the things that actually break in production. The test passes; the feature does not.
Anti-pattern 4: shared QA inbox
await page.fill('input[name=email]', 'qa@yourcompany.com');
// ...later, fetch the latest from the shared inbox
Parallel tests step on each other, the inbox accumulates years of junk, and anyone with the password can read anyone else’s verification codes. Cleanup is its own ten-line script.
The right approach
The right primitive is “give me a fresh inbox per test, and resolve when the OTP arrives.” Two API calls, no sleep, no shared state, no mocking.
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 });
// ...drive the signup with inbox.address...
const code = await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 });
The waiter is a long-poll: it holds an HTTP connection open until the matching message arrives or the timeout fires. p50 latency is ~600ms; p95 is under 1.2s. There is no race condition.
Code samples in four languages
The same flow in Node, Python, Go, and curl. Pick whichever your stack uses.
Node.js
import { CatchOTP } from '@catchotp/sdk';
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 });
console.log(`Sign up with: ${inbox.address}`);
const code = await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 });
console.log(`Got OTP: ${code}`);
await otp.inboxes.delete(inbox.id);
Python
import os
from catchotp import CatchOTP
client = CatchOTP(api_key=os.environ["CATCHOTP_KEY"])
inbox = client.inboxes.create(mode="ephemeral", ttl_minutes=10)
print(f"Sign up with: {inbox.address}")
code = client.inboxes.wait_for_otp(inbox.id, timeout_seconds=30)
print(f"Got OTP: {code}")
client.inboxes.delete(inbox.id)
Go
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/catchotp/catchotp-go"
)
func main() {
client := catchotp.NewClient(os.Getenv("CATCHOTP_KEY"))
ctx := context.Background()
inbox, _ := client.Inboxes.Create(ctx, &catchotp.InboxCreateParams{
Mode: "ephemeral",
TTLMinutes: 10,
})
fmt.Printf("Sign up with: %s\n", inbox.Address)
code, _ := client.Inboxes.WaitForOTP(ctx, inbox.ID, 30*time.Second)
fmt.Printf("Got OTP: %s\n", code)
client.Inboxes.Delete(ctx, inbox.ID)
}
curl
# 1. Create an inbox
INBOX=$(curl -sX POST https://api.catchotp.com/v1/inboxes \
-H "Authorization: Bearer $CATCHOTP_KEY" \
-d '{"mode":"ephemeral","ttlMinutes":10}')
INBOX_ID=$(echo "$INBOX" | jq -r .id)
echo "Sign up with: $(echo "$INBOX" | jq -r .address)"
# 2. Long-poll for the OTP (resolves on arrival)
CODE=$(curl -s "https://api.catchotp.com/v1/inboxes/$INBOX_ID/otp/wait?timeoutSeconds=30" \
-H "Authorization: Bearer $CATCHOTP_KEY" | jq -r .code)
echo "Got OTP: $CODE"
# 3. Clean up
curl -sX DELETE "https://api.catchotp.com/v1/inboxes/$INBOX_ID" \
-H "Authorization: Bearer $CATCHOTP_KEY"
Framework-specific integrations
Playwright
import { test, expect } from '@playwright/test';
import { CatchOTP } from '@catchotp/sdk';
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
test('OTP signup', async ({ page }) => {
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 });
await page.goto('https://example.com/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Continue' }).click();
const code = await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 });
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
Cypress
import { CatchOTP } from '@catchotp/sdk';
const otp = new CatchOTP({ apiKey: Cypress.env('CATCHOTP_KEY') });
it('OTP signup', () => {
cy.wrap(otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 })).then((inbox) => {
cy.visit('/signup');
cy.get('input[name="email"]').type(inbox.address);
cy.get('button[type="submit"]').click();
cy.wrap(otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 })).then((code) => {
cy.get('input[name="code"]').type(code);
cy.get('button[type="submit"]').click();
cy.contains('Welcome').should('be.visible');
});
});
});
Jest
import { CatchOTP } from '@catchotp/sdk';
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
test('signup API issues OTP', async () => {
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 });
await fetch('https://api.example.com/signup', {
method: 'POST',
body: JSON.stringify({ email: inbox.address }),
});
const code = await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 });
expect(code).toMatch(/^\d{6}$/);
});
pytest
import os, pytest
from catchotp import CatchOTP
@pytest.fixture
def otp_client():
return CatchOTP(api_key=os.environ["CATCHOTP_KEY"])
@pytest.fixture
def inbox(otp_client):
box = otp_client.inboxes.create(mode="ephemeral", ttl_minutes=10)
yield box
otp_client.inboxes.delete(box.id)
def test_otp_signup(otp_client, inbox, http):
http.post("/signup", json={"email": inbox.address})
code = otp_client.inboxes.wait_for_otp(inbox.id, timeout_seconds=30)
assert code.isdigit() and len(code) == 6
CI considerations
Three things to think about before you wire this into CI.
1. Concurrency
Most free tiers cap concurrent inboxes at 5. If your CI runs 20 parallel test workers and each creates an inbox, you will hit that cap. Pro plans usually lift this to 50; Team to 500. The cheap fix while evaluating: gate the OTP-using tests behind a @otp tag and run them serially.
2. CI secrets
The API key goes in your CI provider’s secret store. Never commit it. Per-pipeline scoped keys are the right pattern: a separate key for staging E2E, a separate key for nightly regression. A leak in one does not cascade.
3. Network
The SDK uses outbound HTTPS to api.catchotp.com:443. No inbound ports, no SMTP, nothing your CI provider needs to whitelist beyond standard egress. If you have an egress allowlist, add api.catchotp.com to it.
Troubleshooting checklist
When waitForOtp() times out, walk through these in order. Eight times out of ten the answer is in the first three.
| Symptom | Likely cause | Fix |
|---|---|---|
| Timeout, no message at all | Service did not send | Check service logs; verify the email address you used |
| Message arrived but no OTP extracted | Non-standard format | Pass a custom pattern: /your-regex/ |
| Sometimes works, sometimes does not | Multiple emails in inbox | Use subject filter to disambiguate |
| Always times out in CI but works locally | Egress blocked | Allow api.catchotp.com:443 outbound |
| 401 errors | Bad API key | Verify the secret is set in CI |
| 429 errors | Concurrency cap hit | Upgrade plan or serialize tests |
OTP regex tips
The default OTP extractor matches the common patterns: 4-8 digit numeric codes, with optional separators, prefixed by words like code, OTP, verification, or pin. It also handles the case where the code is alone on a line, and the case where it is bolded or styled.
When the default misses, override:
const code = await otp.inboxes.waitForOtp(inbox.id, {
pattern: /verification\s+code:?\s*([a-z0-9]{8})/i,
timeoutSeconds: 30,
});
Three tips that catch most real-world misses:
- Watch case sensitivity. Some services send “Code” capitalized, others “CODE”. Use the
/iflag. - Watch for line breaks inside the code. A 6-digit code rendered as
1 2 3 4 5 6with spaces is not unusual. The default regex tolerates this; custom patterns often do not. - Watch for HTML entities in the body. If you are matching against the HTML body rather than the parsed text, you may see
between digits. Match againstmessage.textinstead ofmessage.html.
When OTP testing is the wrong primitive
Be honest about which step you are testing. If your concern is “does the OTP eventually arrive in production,” you want a synthetic-monitoring tool that tests the production system. If your concern is “does our app correctly handle the OTP we receive,” catchotp is the right tool. Same email, different question.
Run the OTP test today. Free tier with 5 inboxes and 1,000 messages a month. Start free or read the OTP testing use-case page for framework deep-dives.
Related reading
- Best Email Testing Tools for Developers in 2026
- How to E2E Test Sign-Up Flows With Real Emails
- The OTP testing use case covers framework-specific patterns in more depth.
The hardest part of OTP testing in 2026 is unlearning the patterns from when there was no good primitive. With one, the test becomes three lines and stops being flaky. The rest of the suite gets to assume the user is logged in.