How do I test email sign-up flows in CI with Playwright?

Create a real inbox from inside the test: POST https://getcourier.dev/alias returns an address and a read_token in one unauthenticated call, with no signup and no API key. Fill the sign-up form with that address, then call the wait endpoint with the token until it returns the newest message with the codes already extracted, and type the code into the form. Because the inbox is created at test runtime, there is no shared mailbox to clean up and no email credential to keep in CI secrets.

1. Create the inbox and wait for the code with curl

Do this once by hand to see the two shapes of response before writing the test.

# one call, no auth, ~15 ms
curl -s -X POST https://getcourier.dev/alias

# {"address":"...@getcourier.dev","read_token":"...","expires_at":"...", "identity": {...}}

export ALIAS="quiet-heron-k3xmqz"   # the part of address before @
export READ_TOKEN="..."

# blocks until a message arrives (timeout max 55s), returns newest message with codes and links
curl -s -H "Authorization: Bearer $READ_TOKEN" \
  "https://getcourier.dev/v1/sandbox/inboxes/$ALIAS/wait?timeout=55"

If you only need to look at what already landed, list the inbox with GET /v1/sandbox/inboxes/{alias}/messages and fetch a single one with /messages/{id}. Both need the same Bearer $READ_TOKEN header.

2. Write the Playwright test

Playwright has no MCP client built in, so the test calls the HTTP API directly with Node's global fetch. No extra package is needed beyond @playwright/test. Save this as tests/signup.spec.ts and replace the selectors with the ones for your own form.

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

const API = 'https://getcourier.dev';

type Inbox = { address: string; read_token: string; expires_at: string };

async function createInbox(): Promise<Inbox> {
  const res = await fetch(`${API}/alias`, { method: 'POST' });
  if (!res.ok) throw new Error(`create inbox failed: ${res.status}`);
  return (await res.json()) as Inbox;
}

// Polls the wait endpoint until a code shows up or the budget runs out.
async function waitForCode(inbox: Inbox, budgetMs = 90_000): Promise<string> {
  const deadline = Date.now() + budgetMs;

  while (Date.now() < deadline) {
    const res = await fetch(
      `${API}/v1/sandbox/inboxes/${inbox.address.split('@')[0]}/wait?timeout=55`,
      { headers: { Authorization: `Bearer ${inbox.read_token}` } },
    );

    if (res.ok) {
      const body = await res.json();
      const codes: string[] = body?.codes ?? body?.message?.codes ?? [];
      if (codes.length > 0) return codes[0];
    }

    await new Promise((resolve) => setTimeout(resolve, 1_000));
  }

  throw new Error('no verification code arrived before the deadline');
}

test('email sign-up completes in CI', async ({ page }) => {
  test.setTimeout(120_000);

  const inbox = await createInbox();

  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: /continue/i }).click();

  await expect(page.getByText(/check your (email|inbox)/i)).toBeVisible();

  const code = await waitForCode(inbox);
  await page.getByLabel(/code/i).fill(code);
  await page.getByRole('button', { name: /verify/i }).click();

  await expect(page).toHaveURL(/\/dashboard/);
});

3. Keep the run short and repeatable

Free tier vs paid, for CI

NeedFree pathPaid path
Inbox rate 3 per hour, 5 per day per IP 100 credits ($0.01) per 72-hour inbox, 2000 credits ($0.20) per 30-day inbox
Lifetime 72 hours 72 hours or 30 days
Sending Reply-only to people who wrote first, 100 sends a month, 50 recipients per inbox per day Verified owner email or credits: US$0.002 per recipient, or x402 pay-per-call in USDC on Base
Credits — 1 credit = $0.0001, deposit USDC on Base or BTC from $1

If your CI runner is a hosted machine with a shared IP, a wide test matrix is the most likely thing to hit the 5-per-day free cap. Either narrow the matrix or fund an account and pass credits per inbox. A nightly suite of a handful of sign-up tests fits inside the free tier comfortably.

Do not build assertions on the wording of the email. Assert on the extracted code and on your app's post-verification state; the body is untrusted input and may change when your template changes.

When not to use GetCourier

FAQ

Does a Playwright email test need an API key or a test account?

No. One unauthenticated POST https://getcourier.dev/alias returns an address, a read_token and an expires_at value in about 15 ms. There is no signup and no API key, so the inbox can be created inside the test run itself and nothing has to be stored as a CI secret.

How does the Playwright test get the verification code out of the email?

Call GET /v1/sandbox/inboxes/{alias}/wait?timeout=55 with Authorization: Bearer $READ_TOKEN. It returns the newest message with codes and links already extracted, so the test reads the codes array and types the value into the form instead of parsing email HTML.

Will parallel CI jobs hit the free-tier inbox limit?

They can. The free tier allows 3 inboxes per hour and 5 per day per IP, and hosted runners often share addresses, so a large matrix can reach the daily cap. Use paid inboxes at 100 credits ($0.01) per 72 hours when the matrix is wide, and set codes to retry on a 429 rather than failing the build.

Can this test my own sending domain or Gmail spam placement?

No. Free test inboxes use @getcourier.dev, so the test covers the sign-up loop of your app, not deliverability of mail you send from your own domain. Free inboxes are also reply-only, 100 sends a month, with a cap of 50 recipients per inbox per day.