AI agent or LLM? Read /llms.txt for a structured overview instead.
Use case ยท Testing

Test email OTP codes

Retrieve one-time codes from received email for signup, login, or sensitive-action checks. This guide covers email OTPs, not SMS codes or authenticator-generated TOTP. Retry while delivery is pending.

No credit card required.

How it works

From triggered code to verified login

Works the same way whether the code guards a signup, a login, or a sensitive action.

  1. 1

    Trigger the code

    Call whatever action in your app sends a one-time code โ€” signup, login 2FA, a password reset, or a sensitive action confirmation.

  2. 2

    Inbox receives it

    Programmable Inbox receives the real email the moment your app sends it โ€” not a mock SMTP server, not an intercepted request.

  3. 3

    Extract in one call

    GET /otp returns the most recent code, already parsed out of the subject or body โ€” no regex, no per-template parsing logic.

  4. 4

    Assert & handle expiry

    Feed the code back into your test and assert on the result โ€” including the negative case, where an expired or reused code should be rejected.

Login prerequisite: seed a user in your test application with the generated inbox address and a known test password before submitting the login form. The inbox API creates a mailbox, not an account in your app. Adapt this setup to your own test fixtures.

Integration example

A 2FA login test, with real retry handling

Codes can take a few seconds to arrive โ€” this example retries instead of guessing with a fixed sleep.

Illustrative integration templates: these examples have not been executed against your app.Install the linked SDK and Playwright test runner (or pytest-playwright for Python), install a Playwright browser, and set PI_API_KEY in your environment. Use an approved receiving domain and a key with email_inboxes:create and email_messages:read for tests that create inboxes. Replace URLs, selectors, search terms, and assertions with your application contract. Keep credentials out of source control, use unique addresses, and delete test inboxes after runs with a separately authorized cleanup key.

import { test, expect } from '@playwright/test'
import { randomUUID } from 'node:crypto'
import { Configuration, EmailInboxesApi } from '@programmableinbox/sdk'

const config = new Configuration({ accessToken: process.env.PI_API_KEY })
const api = new EmailInboxesApi(config)

// Codes can take a few seconds to land โ€” retry instead of a fixed sleep
async function waitForOtp(inboxId: string, attempts = 4, delayMs = 3000) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await api.getEmailInboxOtp({ id: inboxId, withinMinutes: 5 })
    } catch (err) {
      if (i === attempts - 1) throw err
      await new Promise((r) => setTimeout(r, delayMs))
    }
  }
}

test('login sends and accepts a working 2FA code', async ({ page }) => {
  // 1. Create a fresh inbox for this test run
  const inbox = await api.createEmailInbox({
    createEmailInboxRequest: {
      email: `2fa-${randomUUID()}@mail.programmableinbox.com`,
      name: '2FA login test',
    },
  })

  // REQUIRED: seed an app user with inbox.data.email and this test password.
  // 2. Log in with a password โ€” this is what triggers the email OTP
  await page.goto('https://staging.yourapp.com/login')
  await page.fill('#email', inbox.data.email)
  await page.fill('#password', 'correct horse battery staple')
  await page.click('button[type=submit]')

  // 3. Read the code back, retrying while it's in transit
  const otp = await waitForOtp(inbox.data.id)

  // 4. Finish the flow and assert
  await page.fill('#code', otp!.data.otp)
  await page.click('button[type=submit]')
  await expect(page.locator('[data-testid=account-menu]')).toBeVisible()
})

FAQ

Common questions

What OTP formats does extraction handle?
Programmable Inbox looks for the common patterns โ€” numeric codes of typical length, and codes labeled things like "verification code" or "security code" in the subject or body โ€” so you don't write a separate regex per email template your app (or a provider you're testing) uses.
How do I test an expired or already-used code?
Request the OTP as usual, then wait past your app's expiry window (or submit a code from an earlier test run) before asserting the result. Programmable Inbox doesn't invalidate codes on its end โ€” expiry and reuse rejection is entirely your application's behavior under test, which is exactly what you want to exercise.
Do I need to poll for the code, or does the call wait for it?
GET /otp returns whatever's already arrived within the window you specify โ€” it doesn't block until an email shows up. Codes can take a few seconds to land, so the example below wraps the call in a small retry loop instead of a fixed sleep.
Can I run OTP tests in parallel without codes colliding?
Yes โ€” give each test run its own inbox, as in the example below. Parallel CI jobs each read back their own code instead of racing over a shared inbox, which is the main way OTP tests get flaky in the first place.
Does this work for login 2FA, not just signup verification?
Yes โ€” the same GET /otp call works for any flow that emails a code: signup verification, login 2FA, password reset, or a sensitive-action confirmation. The example below specifically tests a 2FA login.

Spin up your secondary inbox

Create a programmable address, grab your first OTP, and wire up a rule in minutes. Self-host for free, or sign up for managed cloud โ€” either way, you own your data.

No credit card required ยท AGPL v3 license ยท Community supported