Capture real OTPs and magic links inside Playwright, Cypress, and Vitest tests. Works with Better Auth, Clerk, Auth.js, Supabase, and any custom auth stack.
The 30-second version — install the SDK, drop it into a Playwright test, and you're done. No inbox provisioning, no regex parsing.
import { test, expect } from '@playwright/test';
import { SandboxClient } from '@otpmagiclink/playwright';
const sandbox = new SandboxClient({
apiKey: process.env.OTP_API_KEY!,
});
test('signup with OTP', async ({ page }) => {
await page.goto('/signup');
await page.fill('[name=email]', 'test@yourapp.com');
await page.click('button[type=submit]');
const otp = await sandbox.waitForOtp('test@yourapp.com');
await page.fill('[name=otp]', otp);
await expect(page).toHaveURL('/dashboard');
});You already write E2E tests. Add one line to grab the code your app just emailed.
The Playwright SDK is a standalone npm package with zero runtime dependencies. Cypress users can use the same client — theSandboxClient works in any Node context.
npm install --save-dev @otpmagiclink/playwrightpnpm add -D @otpmagiclink/playwrightyarn add -D @otpmagiclink/playwrightGet an API key from /dashboard and set OTP_API_KEY in your .env.test or CI secrets.
All methods return promises. Timeouts default to 10 seconds; adjust with { timeout: 30_000 }.
waitForOtp(identifier, options?)
Polls the sandbox until an OTP arrives for identifier, then returns the code. Throws SandboxTimeoutError on timeout.
const otp = await sandbox.waitForOtp('user@example.com', {
timeout: 15_000,
pollInterval: 250,
});
await page.fill('[name=otp]', otp);waitForMagicLink(identifier, options?)
Polls until a magic-link URL arrives and returns it. Use with page.goto() to complete the flow.
const link = await sandbox.waitForMagicLink('user@example.com');
await page.goto(link);
await expect(page).toHaveURL('/dashboard');followMagicLink(page, identifier, options?)
Convenience — waits for the link, then navigates page to it. Accepts any object with a goto method (no hard Playwright dependency).
await sandbox.followMagicLink(page, 'user@example.com');getInbox(identifier, limit?)
Non-polling — returns the current message list for one identifier (newest first). Useful for assertions on subject or body content.
const messages = await sandbox.getInbox('user@example.com');
expect(messages[0].subject).toContain('Welcome');advanceClock(seconds) / resetClock()
Advance the sandbox virtual clock to test link/OTP expiry without waiting. Always call resetClock() in afterEach to prevent leakage.
test('magic link expires after 1 hour', async ({ page }) => {
await sandbox.advanceClock(3_600);
const link = await sandbox.waitForMagicLink('user@example.com');
await page.goto(link);
await expect(page.getByText('Link expired')).toBeVisible();
});
test.afterEach(async () => {
await sandbox.resetClock();
});The pattern is always the same: point your auth library's delivery function at the sandbox in test mode, then use waitForOtp() in your tests.
Better Auth
Route the emailOTP plugin's sendVerificationOTP to the sandbox when NODE_ENV === "test".
import { betterAuth } from 'better-auth';
import { emailOTP } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
emailOTP({
async sendVerificationOTP({ email, otp }) {
if (process.env.NODE_ENV === 'test') {
return fetch(`${process.env.OTP_SANDBOX_URL}/v1/verifications`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifier: email,
channel: 'EMAIL',
kind: 'OTP',
}),
});
}
return resend.emails.send({
from: 'auth@yourapp.com',
to: email,
subject: 'Your code',
text: `Your code is ${otp}`,
});
},
}),
],
});Clerk
Use Clerk's +clerk_test email suffix to route test signups through the sandbox. Clerk auto-fills a fixed OTP for those addresses in dev mode — capture it via the SDK.
test('clerk email OTP signup', async ({ page }) => {
const email = 'test+clerk_test@yourapp.com';
await page.goto('/sign-up');
await page.fill('input[name=emailAddress]', email);
await page.click('button[type=submit]');
const otp = await sandbox.waitForOtp(email);
await page.fill('input[name=code]', otp);
await expect(page).toHaveURL('/dashboard');
});Auth.js (NextAuth)
Auth.js magic links go through the sendVerificationRequest callback on the Email provider. Route it to the sandbox in test mode.
import EmailProvider from 'next-auth/providers/email';
export const authOptions = {
providers: [
EmailProvider({
server: process.env.EMAIL_SERVER,
from: 'noreply@yourapp.com',
async sendVerificationRequest({ identifier: email, url }) {
if (process.env.NODE_ENV === 'test') {
return fetch(`${process.env.OTP_SANDBOX_URL}/v1/verifications`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifier: email,
channel: 'EMAIL',
kind: 'MAGIC_LINK',
redirectUrl: url,
}),
});
}
// Normal production path
},
}),
],
};Custom auth / any framework
If your auth calls fetch() or SMTP to send OTPs, swap the destination for POST /api/v1/verifications in test mode. The REST reference below covers the payload shape.
The SDK is a thin wrapper over a small REST API. Use REST directly if you're testing from a non-Node language or need control the SDK doesn't expose. Base URL: https://your-domain.com/api/v1
sandbox.waitForOtp()) to retrieve it.Download the API spec and import it into Postman, Insomnia, or any OpenAPI-compatible client. The Postman collection includes collection variables and test scripts that chain verificationId and otpToken across requests.
Import into Postman
Download a ready-made collection with variables. Set baseUrl and apiKey, then run requests — verificationId and otpToken are saved automatically from responses.
Recommended — includes {{baseUrl}}, {{apiKey}}, {{verificationId}}, and auto-chaining scripts.
Import into Postman, Insomnia, or Swagger. Bearer auth and all public endpoints.
Postman: Import → choose file → open collection **Variables** tab → set apiKey to your sk_… key.
Pick a project and API key, edit the sample payload, and send live requests against /api/v1. Sandbox projects are recommended for testing — no real email or SMS is sent.
Send your project API key on every request:
Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxKeys use the sk_ prefix. Sandbox project keys never send real email or SMS.
Creates a verification and delivers the OTP or magic link.
| Field | Type | Req | Description |
|---|---|---|---|
| identifier | string | required | Email address or E.164 phone (+15551234567). |
| channel | "EMAIL" | "SMS" | required | Delivery channel. |
| kind | "OTP" | "MAGIC_LINK" | required | OTP code or clickable magic link. |
| redirectUrl | string (URL) | optional | Redirect after magic link click. Must match allowlist if configured. |
| metadata | object | optional | Arbitrary key/value pairs stored with the verification. |
{
"identifier": "user@example.com",
"channel": "EMAIL",
"kind": "OTP"
}{
"identifier": "user@example.com",
"channel": "EMAIL",
"kind": "MAGIC_LINK",
"redirectUrl": "https://yourapp.com/dashboard"
}{
"identifier": "+15551234567",
"channel": "SMS",
"kind": "OTP"
}{
"id": "cmqz5di97000qq48tkwksjltp",
"status": "PENDING",
"channel": "EMAIL",
"kind": "MAGIC_LINK",
"expiresAt": "2026-06-29T12:01:14.740Z"
}{
"id": "cmqz5di97000qq48tkwksjltp",
"status": "PENDING",
"channel": "EMAIL",
"kind": "MAGIC_LINK",
"expiresAt": "2026-06-29T12:01:14.740Z",
"sandbox": {
"message": "No real email is sent in sandbox. Use the sandbox inbox or GET /api/v1/sandbox/inbox/{identifier}/latest-link to retrieve the magic link or OTP."
}
}{
"error": "Message delivery failed. Check your delivery config.",
"detail": "Resend delivery failed: Domain not verified"
}Sandbox projects capture messages instead of sending real email or SMS. Read them in the dashboard inbox or via API.
All captured messages for an identifier, newest first.
{
"messages": [
{
"id": "msg_01jxxx",
"identifier": "user@example.com",
"subject": "Your verification code is 482910",
"otp": "482910",
"magicLink": null,
"createdAt": "2026-06-29T12:00:00.000Z"
},
{
"id": "msg_01jyyy",
"identifier": "user@example.com",
"subject": "Your sign-in link",
"otp": null,
"magicLink": "https://your-domain.com/api/v1/verify/magic?token=...&id=...",
"createdAt": "2026-06-29T11:58:00.000Z"
}
]
}Most recent magic link for an identifier.
{
"id": "msg_01jyyy",
"identifier": "user@example.com",
"magicLink": "https://your-domain.com/api/v1/verify/magic?token=...&id=...",
"otp": null,
"createdAt": "2026-06-29T11:58:00.000Z"
}{
"error": "No magic link found for this identifier"
}Use layered checks to catch API issues early — from lightweight uptime probes to full end-to-end verification flows.
Liveness — GET /api/health
Returns 200 when the process is running. Use for load balancer pings (no DB/Redis check).
Readiness — GET /api/health/ready
Returns 200 only when PostgreSQL and Redis are reachable. Returns 503 with dependency status when degraded — ideal for uptime monitors (Better Stack, Pingdom, etc.).
CLI smoke test — yarn smoke
Runs health, readiness, and a full sandbox OTP flow (create → inbox → check). Exit code 0 = pass. Use locally or in CI.
BASE_URL=https://your-app.run.app API_KEY=sk_sandbox_key yarn smokeCI runs Playwright API tests before deploy, then yarn smoke after deploy. If smoke fails, the GitHub Actions workflow fails (staging on main, dev on develop). Required secrets: K6_STAGING_API_KEY / K6_DEV_API_KEY (sandbox sk_… keys).
Errors return JSON with an error string and optional detail or details.
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key. |
| 403 | Sandbox endpoint called on a non-sandbox project. |
| 404 | Verification or resource not found. |
| 409 | Already verified. |
| 410 | Expired or max attempts reached. |
| 422 | Invalid request body. |
| 429 | Rate limit exceeded. |
{
"error": "Invalid request body",
"details": {
"fieldErrors": {
"channel": ["Invalid enum value. Expected 'EMAIL' | 'SMS'"]
}
}
}Masked preview only. Requests are authenticated server-side using the selected project and key — the full secret is never sent to the browser.
/api/v1/verificationsPOST /api/v1/verifications Authorization: Bearer sk_xxxxxxxxxxxx
Response
Send a request to see the live response here.
Create an OTP or magic-link verification. · Authenticated calls are proxied through /api/dashboard/simulator/proxy to /api/v1