Browser Agent Signups: Handling Email Verification and OTPs End to End
A browser agent can navigate a signup page, invent a password, and click submit. Then it hits a wall that no amount of DOM reasoning gets past: "We've sent a code to your email." The agent has no inbox, so the run stalls. This is the single most common reason autonomous signups fail. Here is the whole pattern, with runnable code, for getting a Playwright, Browser Use, Stagehand, or Steel agent through email verification without a human watching.
Why agents stall at "check your email"
Email verification is a deliberate speed bump. The service wants proof that whoever filled the form controls a real, reachable mailbox before it grants an account. For a human that is trivial. For an agent it is a dead end, because the mailbox usually lives somewhere the agent cannot reach: a personal Gmail account, a shared team inbox, or nowhere at all.
The workarounds people reach for first tend to rot quickly. Hardcoding one QA mailbox means every parallel run collides on the same address and the same unread codes. Wiring up the Gmail API drags in OAuth consent screens, a GCP project, Pub/Sub push subscriptions, and sending limits that Google will happily throttle or ban when it sees automation at scale. Screen-scraping a webmail UI inside the same browser session is brittle and slow. None of these give the agent a clean, programmatic mailbox it owns.
What the agent actually needs is small and specific: an address it can mint on demand, a way to block until the verification email lands, and a deterministic way to pull the six digits or the magic link out of the message. That is the entire job. Everything below is those three things, wired together.
It is worth naming how often this bites, because it is easy to under-weight until you run agents at volume. In practice the email gate is where a large share of unattended signup runs die not because the model reasoned badly, but because there was simply no mailbox to read. A flow that works flawlessly in a demo, where a human quietly forwards the code, quietly falls over the moment you remove the human. Solving the email step once, cleanly, tends to recover more end-to-end success than another round of prompt tuning.
The end-to-end flow
The shape of the flow never really changes, whether you drive the browser with Playwright directly or hand it to a higher-level agent framework. You create an inbox, put its address into the form, wait for mail, extract the value, and finish the verification in the browser.
1. POST /mailboxes -> agent-3f9c@devinbox.io
2. [browser fills the signup form with that address]
3. GET /messages/{key}/wait?timeout=90 -> blocks until mail lands
4. GET ...?template=saas-otp -> { "code": "482913" }
5. [browser types 482913 and submits] -> account verified
Steps 1, 3, and 4 are ordinary HTTPS calls to https://api.devinbox.io, authenticated with an X-Api-Key header. Steps 2 and 5 are whatever browser automation you already use. The browser driver and the email plumbing are fully decoupled, which is why the same recipe works across frameworks.
1. Give the agent an inbox it controls
A single POST /mailboxes call returns a fresh inbox. Send isTemporary: true and you get a throwaway address keyed by a random string, perfect for a dry run you never intend to revisit. The response carries a key (the local part of the address) and a password for SMTP/IMAP if you ever want protocol access.
const API = 'https://api.devinbox.io';
const headers = { 'X-Api-Key': process.env.DEVINBOX_API_KEY };
// Throwaway inbox for a dry run
const res = await fetch(`${API}/mailboxes`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ isTemporary: true })
});
const inbox = await res.json(); // { key, password }
const address = `${inbox.key}@devinbox.io`;
When the account is one the agent must keep, create a persistent inbox instead: pass isTemporary: false and a name, and the name becomes the local part of a stable address. So { name: "checkout-agent", isTemporary: false } gives your agent checkout-agent@devinbox.io for good. That matters because the verification email is rarely the last message the account ever sends. The password reset next month, the receipt after a purchase, the security alert after a login all land in the same place, and a persistent inbox is still there to receive them.
Scope the key to one project
Agents run unattended, so give them a least-privilege API key. Creating and deleting inboxes needs inbox:write; reading mail and running extraction needs inbox:read. A key can also be confined to a single project, so a fleet of agents stays isolated one key each.
2. Submit the signup form with that address
This step is just your normal browser automation, with the DevInbox address dropped into the email field. Here it is in plain Playwright; a Browser Use or Stagehand agent would express the same intent in natural language, but the effect is identical.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://app.example.com/signup');
await page.fill('input[name="email"]', address);
await page.fill('input[name="password"]', 'Correct-Horse-9-Battery');
await page.click('button[type="submit"]');
The moment the form posts, the target service queues its verification email. That timing is the crux of the next step, so before you click submit, grab a timestamp. You will use it to tell the wait endpoint exactly which message you are looking for.
3. Wait for the email with one long-poll call
The naive instinct is a sleep-and-poll loop: sleep five seconds, list messages, repeat. It works until it doesn't. Sleep too little and you burn API calls on an empty inbox; sleep too much and your agent sits idle while the code's expiry timer ticks down. Worse, every iteration is a round trip you pay for.
DevInbox replaces the whole loop with a single blocking request. GET /messages/{key}/wait holds the connection open and returns the instant a matching message arrives, or a 204 No Content when the timeout elapses. The window defaults to 60 seconds and is capped at 120. One call, no loop, no polling interval to tune.
Beat the race with since
By default the wait endpoint only returns messages received after the moment you call it. If the email happens to land in the split second before your request arrives, a naive call would miss it and wait out the full timeout. Pass since a timestamp you captured just before submitting the form and the endpoint will return any qualifying message received after that instant, race eliminated.
// Captured just before we clicked submit
const since = new Date().toISOString();
// ...fill and submit the signup form...
// One request that blocks until the mail lands (or 90s passes)
const waitRes = await fetch(
`${API}/messages/${inbox.key}/wait` +
`?timeout=90&since=${encodeURIComponent(since)}`,
{ headers }
);
if (waitRes.status === 204) {
throw new Error('No verification email arrived within 90s');
}
const message = await waitRes.json();
// { uniqueId, from, to, subject, body, isHtml, received }
At this point message.body is the raw email text (or HTML, when isHtml is true). You could regex the code out yourself, but there is a cleaner, more durable option built for exactly this.
4. Extract the OTP or magic link deterministically
An extraction template is a pattern with {{variable}} placeholders that DevInbox matches against the subject and body and returns as structured JSON. There is no language model in the path, no tokens spent, and no chance of a hallucinated value. The same email in always produces the same output out, which is what you want in an automated flow that has to be trusted overnight.
It is tempting to skip templates entirely and drop the raw email into the agent's context, letting the model read the code off it. That works until it doesn't: a model will occasionally transpose a digit, grab an unrelated number from the footer, or re-read a long HTML email on every retry and burn tokens doing it. A template moves the parse out of the probabilistic path and into a fixed regular expression, so the six digits you act on are exactly the six digits that arrived. For a step that gates account creation, that determinism is worth more than the flexibility of free-form reading.
Define the template once, keyed by a name you choose. For a typical one-time-code email the body pattern is a single line:
Template key: saas-otp
Body pattern: Your verification code is {{code}}
# For a magic-link email instead:
Body pattern: Confirm your address here: {{link}}
Create it in the dashboard, or have the agent define its own at runtime with the create_template MCP tool (dry-run it first with test_template, which saves nothing). Then, instead of a second call, just add &template=saas-otp to the same wait request you already make. When a template key is supplied, the arriving message comes back parsed: subject and body are no longer strings but dictionaries of the values you named.
const waitRes = await fetch(
`${API}/messages/${inbox.key}/wait` +
`?timeout=90&template=saas-otp&since=${encodeURIComponent(since)}`,
{ headers }
);
const parsed = await waitRes.json();
// parsed.body is a dictionary of the {{variables}} you named
const otp = parsed.body?.code; // "482913"
if (!otp) {
throw new Error('Email arrived but did not match the saas-otp template');
}
If the message arrives but the pattern does not match, the corresponding dictionary comes back empty rather than throwing, so the guard above tells you plainly whether the sender changed their email format. When the mail has already arrived and you are not blocking, the same extraction is available on GET /messages/{key}/{template}/last, which parses the most recent message with the named template.
A template can pull from both the subject and the body, so you can capture a code that lives in the subject line ("Your code is 482913") and a name or link from the body in one pass, each landing in its own dictionary. The parser works over the raw message content, HTML or plain text, and matches non-greedily, so adjacent placeholders each grab the smallest span that satisfies the pattern. That keeps a magic-link capture from swallowing the rest of the paragraph. Keep patterns anchored to stable surrounding text rather than to exact whitespace, and they survive minor formatting churn from the sender.
5. Finish verification in the browser
With the value in hand, hand control back to the browser. For a one-time code, type it into the verification field and submit. For a magic link, navigate straight to the captured URL there is no need to render the email, because you already extracted the exact link the user was meant to click.
// OTP path: type the code and submit
await page.fill('input[name="otp"]', otp);
await page.click('button[type="submit"]');
await page.waitForURL('**/welcome');
// Magic-link path: just visit the extracted URL
// const link = parsed.body?.link;
// await page.goto(link);
That is the loop closed: the agent created its own address, received a real email, pulled out the exact value, and cleared the gate no human, no shared mailbox, no OAuth dance.
Two unhappy paths deserve explicit handling. When wait returns a 204, the email genuinely did not arrive inside your window; the safe recovery is to click the site's resend control and issue a fresh wait with a new since timestamp, rather than assume the first message is merely late and keep waiting on it. And because one-time codes expire, keep the gap between extraction and submission short. Run the wait-and-extract immediately before you type the code, not minutes earlier, so a slow browser step never feeds the form a code that has already gone stale.
The whole flow in one script
Here are the pieces assembled into a single runnable script. Swap the temporary inbox for { name: "checkout-agent", isTemporary: false } when the account is one the agent needs to keep answering for later.
import { chromium } from 'playwright';
const API = 'https://api.devinbox.io';
const headers = { 'X-Api-Key': process.env.DEVINBOX_API_KEY };
// 1. Mint an inbox the agent controls
const mb = await fetch(`${API}/mailboxes`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ isTemporary: true })
});
const inbox = await mb.json();
const address = `${inbox.key}@devinbox.io`;
// 2. Drive the signup form
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://app.example.com/signup');
await page.fill('input[name="email"]', address);
await page.fill('input[name="password"]', 'Correct-Horse-9-Battery');
const since = new Date().toISOString();
await page.click('button[type="submit"]');
// 3 + 4. Block for the mail and extract the code in one call
const waitRes = await fetch(
`${API}/messages/${inbox.key}/wait` +
`?timeout=90&template=saas-otp&since=${encodeURIComponent(since)}`,
{ headers }
);
if (waitRes.status === 204) throw new Error('No email in time');
const parsed = await waitRes.json();
const otp = parsed.body?.code;
// 5. Finish verification
await page.fill('input[name="otp"]', otp);
await page.click('button[type="submit"]');
await page.waitForURL('**/welcome');
await browser.close();
Temporary vs persistent: which inbox for what
The choice comes down to one question: does the account outlive the run? A dry run against a staging environment, a smoke test, or a throwaway trial can use a temporary inbox and never think about it again. An account the agent will log back into, reset the password for, or receive receipts at needs a persistent inbox so those later messages have somewhere to land.
| Aspect | Temporary inbox | Persistent inbox |
|---|---|---|
| Address | Random key, e.g. a1b2c3...@devinbox.io |
Chosen name, e.g. checkout-agent@devinbox.io |
| Lifetime | Ephemeral, auto-expiring | Stable until you delete it |
| Create with | { isTemporary: true } |
{ name, isTemporary: false } |
| Best for | Dry runs, smoke tests, one-off signups | The agent identity that keeps the account |
| Later mail (resets, receipts) | Not guaranteed to survive | Still received months later |
| Counts toward plan inbox limit | No unlimited on every tier | Yes 3 on Free, up to 300 on Scale |
Because temporary inboxes are unlimited on every plan, including Free, there is no reason to reuse one address across parallel runs. Give each concurrent agent its own throwaway inbox and their verification codes can never cross wires.
There is one more axis worth knowing about: both inbox types can live on your own verified domain instead of devinbox.io. Add a custom domain, verify it over DNS, and your agent's address becomes something like agent@yourbrand.com. That matters more than it sounds for signups, because a growing number of services silently reject addresses from domains they recognize as disposable-mail providers. A persistent inbox on a domain you own reads as an ordinary corporate mailbox and clears those filters, and custom domains are unlimited on every paid plan.
Driving it from an MCP agent
If your agent speaks the Model Context Protocol whether that is Claude, Cursor, or a framework like Browser Use wired to an MCP client you do not need the fetch calls at all. Point the client at the hosted DevInbox MCP server and the same primitives show up as tools the model can call directly.
claude mcp add --transport http devinbox https://mcp.devinbox.io \
--header "Authorization: Bearer YOUR_API_KEY"
From there the agent runs the identical flow as tool calls, with the browser steps in between:
1. create_inbox() -> agent-3f9c@devinbox.io
2. [browser fills the signup form with that address]
3. wait_for_email(inbox_key, timeout_s=90,
template="saas-otp") -> { "code": "482913" }
4. [agent types 482913 and submits] -> account verified
wait_for_email is the same long-poll under the hood, and it returns a plain "no email arrived" message on timeout instead of an error, so the model can branch and retry rather than stall. When the agent meets a sender it has never seen, it can define its own template on the fly with create_template and validate it with test_template before saving. Every MCP tool is a thin wrapper over the public API, so you can mix and match: REST for the browser harness, MCP for the reasoning loop.
What DevInbox does, and doesn't, do here
DevInbox receives the verification email and extracts the code or link from it. It does not send the email that is the target service's job and it does not reply to it on the agent's behalf. Outbound send from a DevInbox inbox is on the roadmap, not shipping today. If your flow needs the agent to answer an email, plan for that separately.
Frequently asked questions
How does a browser agent receive a verification email without a real inbox?
It creates one on demand. A POST /mailboxes call returns a live address on devinbox.io (or your own verified domain) that accepts real SMTP mail from any sender. The agent puts that address in the signup form, and the message lands in an inbox it fully controls.
Why not just poll the inbox in a loop?
Polling forces you to guess an interval and pay for every empty check. The /wait endpoint long-polls: one request holds open and returns the instant the email arrives, up to 120 seconds, then returns 204 if nothing came. No loop, no tuning, and the agent reacts with near-zero latency.
How do I extract the OTP or magic link reliably?
Define an extraction template with a {{code}} or {{link}} placeholder and pass its key to the wait or last-message endpoint. Matching is deterministic and runs without a language model, so the same email always yields the same value no tokens, no hallucinations.
Should the agent use a temporary or persistent inbox?
Temporary for anything disposable dry runs and one-off signups, unlimited on every plan. Persistent when the account must be reachable again later, because password resets, receipts, and security alerts keep arriving long after signup and a persistent inbox is still there to catch them.
Can the agent reply to the verification email or send from the inbox?
Not today. DevInbox is inbound: it receives and extracts. Outbound send and reply are on the roadmap but not available yet, so design flows around receiving and parsing rather than answering mail.
Does this work with Browser Use, Stagehand, or Steel?
Yes. The email steps are plain HTTPS calls (or MCP tool calls) that are completely independent of the browser driver. Whatever framework fills the form, the create-inbox, wait, and extract calls are identical, so the recipe ports across all of them unchanged.
Get your agent past "check your email"
Free tier: 3 persistent inboxes, 1,000 emails a month, and unlimited temporary inboxes. No credit card required. Give your browser agent a real email address in minutes.