Skip to main content
A Playwright test signs up with a fresh testing address, waits for the real email your app sends, reads the code or link, and completes sign-up. There is no DevHelm Playwright package: the test calls @devhelm/sdk (or the Python devhelm package) directly.
  • A Playwright project, and the app under test running at a URL the runner can reach
  • A mail provider in the test environment that delivers to outside addresses. See mail provider notes
  • An API key and workspace ID:
The testing API needs an API key and a workspace ID. Export the key, look up the workspace, then export its ID:
The CLI and Python SDK read both variables. The TypeScript SDK reads DEVHELM_WORKSPACE_ID and takes the key in its constructor. With curl, send the workspace as a header:

Write the test

1

Install the packages

The SDK is ESM only. Set "type": "module" in the test project’s package.json.
2

Set the base URL and test timeout

playwright.config.ts
The runner timeout must be longer than the wait’s timeoutMs. A longer runner timeout does not extend the wait call.
3

Write the sign-up test

tests/signup.spec.ts
client.email.address() returns a new address on the workspace’s assigned domain, creating the domain on first use. Any local part is an inbox, and the random suffix keeps parallel tests and retries apart. since is taken before the click: wait returns the oldest message received at or after it, and without it the SDK uses the time of the wait call, so an email that arrived first is missed. subjectContains is a case-insensitive substring match. Replace the labels, button names, and heading with your app’s.
4

Run it

After the wait, pick the link by its visible text and open it in the same page:
links[] holds {href, text}, one entry per distinct href, kept exactly as written. To match on the destination instead, compare hosts: new URL(l.href).host === "app.example.com". Several codes and the body fallback are on Read OTP codes and links.

pytest-playwright variant

Devhelm() reads DEVHELM_API_TOKEN and DEVHELM_WORKSPACE_ID. Install with pip install pytest-playwright devhelm and run pytest tests/test_signup.py --base-url https://staging.example.com.
tests/test_signup.py
For a link, decode with link.href.replace("&", "&"). The Python message has no text or html; read the body with message.source().

Mail provider notes

The test proves delivery only when the wait returns. When your app reports the email as sent and the wait still times out, start at the provider.
  • A 250 or 202 from your provider is not delivery. It means the provider queued the message. Look the recipient up in the provider’s event log: delivered, deferred, bounced, dropped, or suppressed.
  • Sandbox and test modes do not deliver to outside addresses:
  • Click tracking rewrites links through the provider’s tracking host. DevHelm stores the tracked URL as written and does not unwrap it. Turn click tracking off in the test environment, or open the tracked link with page.goto() and assert on the final page.
  • Suppression lists block an address after a bounce. A fresh address per test is never on one.
  • Over the monthly event cap, mail is not stored, and the wait times out. See Limits, quotas, and errors.
A testing address receives any mail sent to it, and every member of the workspace can read it. Do not point production sending at it, and do not use it for real accounts or customer data.

Without a mail provider

Inject puts a message in the mailbox through the API and runs the same parsing as real mail, so otp[] and links[] come out the same. It returns 202 (accepted, not yet stored) and counts toward monthly events. Two ways to use it:
  • Your app’s test-mode mailer posts to inject (POST /api/v1/email/domains/<domain>/messages/inject with to, from, subject, text, html) instead of the provider. The browser test above runs unchanged.
  • The test injects the email itself. This checks the wait, extraction, and assertions with no app in the loop:
tests/signup-dry.spec.ts

Troubleshooting

A timeout is a DevhelmApiError with status 408 and code WAIT_TIMEOUT, never an empty result. Check in order:
  • since was taken after the click, or not passed. Take it before the action that sends.
  • The provider did not deliver. Check its log for the recipient, then the provider notes.
  • subjectContains does not match. List what arrived with mailbox.messages.list() and read subject.
  • The domain is disabled. Wait on a disabled domain times out instead of failing.
  • The organization hit its monthly event cap.
If Playwright reports “Test timeout exceeded” instead, the runner timeout is shorter than timeoutMs.
Wait returns the oldest match and does not consume it. Call client.email.address() inside each test, not once per file. When the flow sends two emails, use a distinct subjectContains, or wait again with receivedAfter set to the previous message’s receivedAt plus 1 ms. With several codes in one email, otp[] order is not guaranteed, so match on the value.
Every wait call in the organization shares 40 per minute, across email and webhook waits and every workspace. Back off on DevhelmRateLimitError using retryAfter, and size parallel workers to the budget. See Testing in CI.

Next steps

Wait for an email in a test

Matching, repeat sends, and timeouts.

Read OTP codes and links

Several codes, picking links, and reading the body.

Testing in CI

Secrets, per-job isolation, cleanup, and the shared wait budget.

Email testing overview

Domains, addresses, inject vs real mail, and stored fields.