OTPBox / Guides / Magic-link login
Playwright · PasswordlessPasswordless 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.
GET /api/v1/inboxes/:id/links?type=magic_login&timeout=25.page.goto() the URL.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.
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.
type=magic_login keeps only links OTPBox classified as passwordless login. The other types are verification, password_reset, unsubscribe, tracking and general.timeout is in seconds (0–25, default 0). With it, the request waits for the first matching link. Without it, the endpoint returns what's there right now.{ links: [{ messageId, url, host, type, from, subject, receivedAt }] }, newest first and up to 50. If nothing matched in time, links is an empty array rather than an error, which is why the test asserts on its length.from or subject (case-insensitive substrings), messageId and since (epoch milliseconds).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}.
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.
magic_loginThe 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:
type and match on the email instead: ?subject=sign%20in&timeout=25 or ?from=no-reply@your-app&timeout=25.GET /api/v1/inboxes/:id/messages lists each message's linkHost and linkType.expect(links[0].host).toBe('your-app.example.com') catches a misconfigured base URL in the email template.await browser.newContext()) and assert your app shows its "link expired" state instead of signing in again.await page.reload() and re-check the signed-in state, so the test proves a session was actually stored.OTPBOX_KEY in a CI secret. See CI/CD for GitHub Actions, GitLab CI, Jenkins and CircleCI.finally block deletes the inbox even when the test fails. Anything left over expires 1 hour after creation anyway.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.