otpbox

OTPBox / Guides / Magic-link login

Playwright · Passwordless

Test magic-link login with Playwright

Passwordless login moves the whole credential into an email. The user types an address, gets a link and clicks it, and that click is the login. An end-to-end test therefore has to receive that email, find the right link and open it in the same browser context. With OTPBox, a Playwright test gets a real inbox for the address and one API call that waits for the magic link and returns its URL, with nothing mocked and nothing to parse.

The pattern

  1. Create a fresh inbox for the test.
  2. Enter its address on your app's login page and request the link.
  3. Wait for the link on OTPBox's side: GET /api/v1/inboxes/:id/links?type=magic_login&timeout=25.
  4. page.goto() the URL.
  5. Assert you're logged in, then delete the inbox.

All of it uses plain fetch inside @playwright/test, so there's no extra package. You need a key in OTPBOX_KEY. curl -X POST https://otpbox.io/api/v1/keys/free mints a free one with no account (200 requests a month), or use an organization key from the dashboard for CI.

The test

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

const API = 'https://otpbox.io/api/v1';
const headers = { authorization: `Bearer ${process.env.OTPBOX_KEY}` };

test('log in with a magic link', async ({ page }) => {
  // 1. A fresh inbox: { id, address, domain, createdAt, expiresAt, token }
  const inbox = await fetch(`${API}/inboxes`, {
    method: 'POST',
    headers: { ...headers, 'content-type': 'application/json' },
    body: '{}',
  }).then((r) => r.json());

  try {
    // 2. Ask the app to email a login link
    await page.goto('https://your-app.example.com/login');
    await page.fill('[name="email"]', inbox.address);
    await page.click('button[type="submit"]');
    await expect(page.locator('text=Check your email')).toBeVisible();

    // 3. Hold the request open (up to 25 s) until a magic-login link arrives
    const { links } = await fetch(
      `${API}/inboxes/${inbox.id}/links?type=magic_login&timeout=25`,
      { headers },
    ).then((r) => r.json());
    expect(links.length, 'no magic link arrived within 25 s').toBeGreaterThan(0);

    // 4. Opening the link is the login
    await page.goto(links[0].url);

    // 5. Assert on what a signed-in user sees
    await expect(page.locator('text=Dashboard')).toBeVisible();
  } finally {
    await fetch(`${API}/inboxes/${inbox.id}`, { method: 'DELETE', headers });
  }
});

Swap in your own login URL, selectors and signed-in assertion. If your app only sends links to accounts that already exist, sign the address up first in the same test (see the Playwright OTP guide) and then request the login link.

How the links endpoint waits

A fresh inbox per test means the only link in it is the one this test asked for. If one test requests two links (log in, log out, log in again), pass since on the second wait so the first link can't satisfy it: &since=${links[0].receivedAt + 1}.

Alternative: wait for the whole message

To assert on the email itself (subject, wording, sender) as well as the link, use POST /api/v1/inboxes/:id/wait with requireLink. It returns the full message, with the extracted link under message.link:

const res = await fetch(`${API}/inboxes/${inbox.id}/wait`, {
  method: 'POST',
  headers: { ...headers, 'content-type': 'application/json' },
  // since: also match mail that landed before this call started
  body: JSON.stringify({ timeoutSeconds: 25, requireLink: true, since: inbox.createdAt }),
}).then((r) => r.json());

expect(res.timedOut, 'no login email within 25 s').toBe(false);
expect(res.message.subject).toContain('Sign in');   // your email's subject
expect(res.message.link.type).toBe('magic_login');  // { url, host, type }
await page.goto(res.message.link.url);

Unlike /links, /wait only counts mail that arrives after the call unless you pass since. The inbox's own createdAt is the safe value, because it comes from OTPBox's clock and the email may land before your wait begins. A timeout here is a normal 200 with timedOut: true. See Waiting for mail.

When the link isn't classified as magic_login

The link type is OTPBox's reading of the link and the email around it, not something your app declares, so a login link can occasionally land in another bucket. For example, a link rewritten through an email provider's click-tracking redirect may come out as tracking. If the typed query keeps returning an empty list while the email clearly arrived:

Worth asserting beyond "it logged in"

CI notes

Next steps

Sign up, mint a key, and the dashboard shows your first code arriving live. Try it in the dashboard

Rather skip the account? curl -X POST https://otpbox.io/api/v1/keys/free gives you a free key with 200 requests a month, enough to get this test passing today.

← Back to OTPBox