SDK & API Reference

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.

Quick start

The 30-second version — install the SDK, drop it into a Playwright test, and you're done. No inbox provisioning, no regex parsing.

Playwright test
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.

Install SDK

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
npm install --save-dev @otpmagiclink/playwright
pnpm
pnpm add -D @otpmagiclink/playwright
yarn
yarn add -D @otpmagiclink/playwright

Get an API key from /dashboard and set OTP_API_KEY in your .env.test or CI secrets.

SDK reference

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.

waitForOtp
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.

waitForMagicLink
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).

followMagicLink
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.

getInbox
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 expiry
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();
});

Auth stack recipes

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".

auth.ts
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.

clerk-signup.spec.ts
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.

auth.ts
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.

REST API overview

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

  1. Your app calls Create verification, or your auth library's test hook does.
  2. The sandbox captures the OTP or magic link — no real email is sent.
  3. Your test polls Sandbox inbox (or uses sandbox.waitForOtp()) to retrieve it.
  4. Your test fills the OTP into your app's form, or navigates to the magic-link URL.

Postman / OpenAPI

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.

Postman: Import → choose file → open collection **Variables** tab → set apiKey to your sk_… key.

API Simulator

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.

Authentication

Send your project API key on every request:

Authorization header
Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxx

Keys use the sk_ prefix. Sandbox project keys never send real email or SMS.

Create verification

POST/api/v1/verificationsAPI key requiredTry now

Creates a verification and delivers the OTP or magic link.

FieldTypeReqDescription
identifierstringrequiredEmail address or E.164 phone (+15551234567).
channel"EMAIL" | "SMS"requiredDelivery channel.
kind"OTP" | "MAGIC_LINK"requiredOTP code or clickable magic link.
redirectUrlstring (URL)optionalRedirect after magic link click. Must match allowlist if configured.
metadataobjectoptionalArbitrary key/value pairs stored with the verification.
Request body — OTP (email)
{
  "identifier": "user@example.com",
  "channel": "EMAIL",
  "kind": "OTP"
}
Request body — Magic link (email)
{
  "identifier": "user@example.com",
  "channel": "EMAIL",
  "kind": "MAGIC_LINK",
  "redirectUrl": "https://yourapp.com/dashboard"
}
Request body — OTP (SMS)
{
  "identifier": "+15551234567",
  "channel": "SMS",
  "kind": "OTP"
}
Response 201 — production
{
  "id": "cmqz5di97000qq48tkwksjltp",
  "status": "PENDING",
  "channel": "EMAIL",
  "kind": "MAGIC_LINK",
  "expiresAt": "2026-06-29T12:01:14.740Z"
}
Response 201 — sandbox (extra hint)
{
  "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."
  }
}
Response 502 — delivery failed
{
  "error": "Message delivery failed. Check your delivery config.",
  "detail": "Resend delivery failed: Domain not verified"
}

Sandbox

Sandbox projects capture messages instead of sending real email or SMS. Read them in the dashboard inbox or via API.

GET/api/v1/sandbox/inbox/:identifierAPI key requiredTry now

All captured messages for an identifier, newest first.

Response 200
{
  "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"
    }
  ]
}
GET/api/v1/sandbox/inbox/:identifier/latest-linkAPI key requiredTry now

Most recent magic link for an identifier.

Response 200
{
  "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"
}
Response 404
{
  "error": "No magic link found for this identifier"
}

Monitoring & smoke tests

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.

Run against staging or local
BASE_URL=https://your-app.run.app API_KEY=sk_sandbox_key yarn smoke

CI 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

Errors return JSON with an error string and optional detail or details.

StatusMeaning
401Missing or invalid API key.
403Sandbox endpoint called on a non-sandbox project.
404Verification or resource not found.
409Already verified.
410Expired or max attempts reached.
422Invalid request body.
429Rate limit exceeded.
Response 422 — validation error
{
  "error": "Invalid request body",
  "details": {
    "fieldErrors": {
      "channel": ["Invalid enum value. Expected 'EMAIL' | 'SMS'"]
    }
  }
}