Skip to content
catchotp
Use case · OTP testing

OTP testing without the flaky waits.

Skip the 10-second sleep and the production SMS bill. catchotp gives Cypress, Playwright, Jest, and pytest a real waiter that resolves the moment the code lands — typically under 700ms.

  • Real inboxes on a real domain
  • p95 under 1.2s
  • Works with any test runner
OTP testing illustration: six-digit code input next to a passing Playwright test run

The problem with testing OTP flows

Every signup flow with email or SMS verification ends up with the same broken pattern in the test suite: the test fires the signup, then sleeps for ten seconds, then checks a manually-managed inbox, then often retries the whole thing because the email landed two seconds late and the assertion ran first. The test is slow, brittle, and tells you nothing the next morning when it fails in CI.

The honest reason these tests look this way is that there has not been a clean primitive for "give me an inbox, then resolve when the OTP arrives." Teams either skip the verification step entirely (and ship bugs that only show up in production), point the signup form at a personal Gmail (and watch the inbox fill with junk), or pay $500-2,000 a month for an enterprise platform that bundles the primitive inside fifty other features.

The hidden cost is also real: tests that send through your production SMS or transactional email provider rack up per-message fees on every CI run, and a flaky parallel test suite can quietly burn a few hundred dollars a month before anyone notices.

How catchotp solves it

Disposable inboxes per test

Create an inbox in under 100ms, scoped to a single test or test run. TTL them automatically — no cleanup script, no shared state between parallel jobs.

Real-time waiters, not polling

waitForOtp() holds an HTTP long-poll until the matching message arrives, then resolves with the parsed code. No sleep, no jitter, no retries.

OTP auto-extracted

We parse the code out of the email body using a regex that matches the common patterns (code, OTP, verification) — and you can pass your own pattern for edge cases.

Without catchotp vs with catchotp

Concern Without catchotp With catchotp
Wait pattern sleep(10), hope, retry await waitForOtp() resolves on arrival
Per-test isolation Shared mailbox Ephemeral inbox per test
CI cost per run $0.01-0.05 in SMS/email fees $0
OTP parsing Custom regex per service Auto-extracted, override supported
Setup time 2-3 days to wire up 5 minutes

A complete Playwright example

Here is the same test in three runners. Each one creates an ephemeral inbox, drives the signup form, blocks on the OTP, and asserts the post-login state.

import { test, expect } from '@playwright/test';
import { CatchOTP } from '@catchotp/sdk';

const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });

test('user can complete email OTP signup', async ({ page }) => {
  // 1. Spin up an ephemeral inbox scoped to this test
  const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 10 });

  // 2. Drive the signup form with the burner address
  await page.goto('https://example.com/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Continue' }).click();

  // 3. Block until the verification email lands; resolve to the parsed OTP
  const code = await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 });

  // 4. Submit it and assert the post-login state
  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
Save your CATCHOTP_KEY as a CI secret. The SDK uses outbound HTTPS only.

OTP testing FAQ

How long does an OTP wait take with catchotp?
p50 latency from email send to waitForOtp resolution is around 600ms. p95 is under 1.2s end-to-end (sender → SES inbound → parser → long-poll waiter → SDK client). The waiter holds an HTTP long-poll until the matching message is parsed; you do not have to retry, sleep, or implement exponential backoff. Measure it yourself with `time` around the SDK call — most teams are surprised how fast it is.
Can Playwright wait for OTP via catchotp?
Yes — `await otp.inboxes.waitForOtp(inbox.id, { timeoutSeconds: 30 })` resolves with the parsed code as a string. Drop it inline in any Playwright test step, no fixtures or plugins required. The waiter is async and respects Playwright's default test timeout, so a stuck OTP fails the test cleanly with a normal timeout error rather than hanging the worker. The same pattern works in Cypress (wrap in cy.wrap) and pytest.
What happens if the OTP never arrives?
waitForOtp() rejects with a TimeoutError after the configured timeoutSeconds (default 30s). You handle it with a normal try/catch: log the inbox ID, dump the messages list to your CI artifacts, and let the test fail. The inbox web viewer at app.catchotp.com shows every message that arrived (or did not) for that inbox, so you can debug whether it was a sender-side issue, a parser miss, or a routing problem. Common causes: the test triggered the wrong endpoint, the sender greylisted you, or the OTP regex did not match (override with the `pattern` option).
Can I use my real test SMTP server?
You can, but you lose two things: per-test isolation and OTP extraction. catchotp gives every test its own inbox on a real internet-facing domain (inbox.catchotp.com), so you exercise the same DNS, MX, and TLS path your production users do — including DKIM verification and SPF alignment. Self-hosted Mailpit works for unit tests but does not exercise the production envelope, so DKIM/SPF/transactional-provider issues do not show up until prod.
What happens if the OTP regex misses?
You pass a custom pattern: `waitForOtp(inboxId, { pattern: /code:\s*(\d{6})/ })`. The default regex matches 4-8 digit codes after common prefixes (code, OTP, verification, your code is). Misses are rare but easy to override. If you find a sender whose template the default does not match, file an issue — we add patterns to the default set when they are common enough to benefit other users.
Does it work in CI behind a corporate proxy?
Yes. The SDK uses outbound HTTPS to api.catchotp.com:443 only — no inbound ports, no SMTP, nothing your CI provider needs to whitelist beyond standard egress. The SDK respects standard Node/Python proxy environment variables (HTTPS_PROXY, NO_PROXY) so it slots into corporate-proxied CI runners (GitHub Enterprise, self-hosted GitLab) without a custom config.
How much does it cost per test run?
Zero per-test cost. The Free tier covers 1,000 messages and 5 concurrent inboxes per month — enough for a CI pipeline running 30-50 OTP tests per day. Pro at $29/mo lifts that to 50,000 messages and 50 concurrent inboxes (sized for a serious CI suite running thousands of email tests a month). There is no per-test billing, no per-API-call surcharge, and no per-seat fee on Free or Pro.

Catch your first OTP in 60 seconds.

Free tier includes 5 inboxes and 1,000 messages a month — enough for most CI pipelines.