Agent email at scale: 50 parallel signups without breaking
What breaks when an AI agent has to sign up for 50 SaaS products in parallel, verify each one, and extract the trial key. The patterns that work and the rate limits to plan for.
A user asks an agent: “Evaluate the top 50 logging vendors. For each one, sign up for a trial, find out whether they support OpenTelemetry ingest, and tell me the price for 100GB/month.” A human could not finish this in a working day. An agent should be able to do it in five minutes. The hard part is not the LLM reasoning — modern frontier models handle the comparison cleanly. The hard part is everything underneath: 50 inboxes, 50 signup forms, 50 verification emails, 50 trial keys, all running at once, all needing to land. This post is about what breaks at that scale and the patterns that make the job survive contact with reality.
If you have not read the single-agent flow guide on email handling, the basics — create an inbox, wait for the OTP, complete verification — are covered there. This post is about what changes when you go from one agent task to fifty parallel ones.
Why this is hard
The scenario above looks like “one signup × 50” but five different failure modes show up only at concurrency. Each is the kind of thing that does not appear in the demo and does appear in production.
Address collision
An agent that reuses a single email across 50 signups gets blocked instantly. SaaS signup forms reject duplicates, transactional providers throttle the recipient, and any catchotp / Mailosaur-style account-bound inbox trips its own dedup logic. The agent needs 50 distinct addresses, each on a real domain with proper DKIM, each scoped to a single signup task.
The naive workaround — signups+1@gmail.com, signups+2@gmail.com, and so on — fails for two reasons. First, an increasing number of signup forms strip the +alias portion before validating uniqueness, treating all of them as the same address. Second, even where the alias trick works, all 50 verification emails pile into one Gmail inbox and the agent has no scriptable way to read them out. You need genuinely distinct inboxes, programmatically.
Provider rate limits
Gmail, Outlook, and other consumer providers rate-limit how fast they will accept inbound delivery from a single sender. If an agent’s signup runs trigger 50 verification sends in 30 seconds and they all funnel into one provider, you get throttled, deferred, or bounced. The transactional senders on the source side (the SaaS products you are signing up to) usually use Resend / Postmark / Sendgrid / SES, which have their own per-recipient-domain rate limits.
Programmable-email providers including catchotp set their own quotas — both inbound (messages per minute per workspace) and concurrent inbox creation (inboxes created per minute). The exact numbers vary by provider and by tier; the principle is that any service running real abuse-control will rate-limit creation, not just delivery.
Anti-automation in signup forms
A growing number of signup forms detect and reject automation-shaped traffic. The signals: +alias patterns in email, known-disposable domains, missing browser headers, unrealistic submission timing, missing referrer. None of these block the agent on its own, but the cumulative score gets you a CAPTCHA or a rejection.
The mitigations: real domains (which is what programmable-email gives you), realistic submission pacing (deliberate jitter on the agent’s submit cadence), and respect for the actual TOS of the service you are evaluating. The single biggest fix is being on a domain that is not on the disposable-email blocklist — we covered the disposable-email problem in 2026 for context on which domains pass the basic checks.
Parser fragility at concurrency
A single OTP-extraction parser that works 95% of the time on Service A becomes a 22.5% failure rate when you fan out to 50 services with 50 different email templates (0.95^... wait, that's wrong; 5% failure once is ~5%, but at scale you’ll see the diversity tail). The point is: parser misses are rare per service, but distinctive per service. At fan-out scale, you will hit at least three services whose verification email format does not match the default extractor.
The pattern that works is a fallback chain: try the default OTP parser, fall back to a regex on the message body, fall back to a “find the first numeric run of length 4–8 in the body,” fall back to extracting the first verification-link URL. The agent needs to handle “no OTP, but a magic link” gracefully — about 30% of services in 2026 use magic-link verification rather than codes.
Idempotency on retry
When 5 of 50 signups time out (because the email was slow, the signup form 503’d, or the parser missed), the agent needs to retry only those 5 — not all 50. The retry logic needs an idempotency key per task, so a re-issued create-inbox call returns the existing inbox rather than provisioning a new one. Without idempotency, retries cause duplicate accounts, duplicate inboxes, and a parsing pipeline that fights itself for the right verification email.
The pattern that works
Five steps, each independently testable:
- Generate task IDs upfront. One per service. This is the idempotency key you carry through every API call.
- Fan out create-inbox calls with bounded concurrency (a semaphore). Stay under the provider’s create-rate quota.
- Run signups concurrently with the same semaphore. Each task uses its dedicated inbox address.
- Wait for verification messages in parallel. The long-poll shape means waits do not consume client-side resources; you can have 50 outstanding waits without the cost of 50 polling loops.
- Parse with a fallback chain. Default OTP extractor, then regex, then magic-link, then fail with a recoverable error.
The control flow looks like this in pseudocode:
const tasks = services.map(s => ({ id: nanoid(), service: s }));
const sem = new Semaphore(MAX_CONCURRENCY);
const results = await Promise.all(tasks.map(async (task) => {
await sem.acquire();
try {
const inbox = await otp.inboxes.create({
mode: 'ephemeral',
ttlMinutes: 30,
idempotencyKey: task.id,
});
await signupAt(task.service, inbox.address);
const message = await otp.messages.waitFor(inbox.id, { timeoutSeconds: 90 });
const code = extractCode(message); // fallback chain inside
const trialKey = await completeVerification(task.service, inbox.address, code);
return { task, status: 'ok', trialKey };
} catch (err) {
return { task, status: 'error', error: err.message };
} finally {
sem.release();
}
}));
The semaphore is doing real work here. Without it, all 50 tasks fire create_inbox at the same time and the provider throttles you. With a sane concurrency limit (we use 10 by default for this pattern), the create-rate stays within quota, the signup-rate spreads evenly across the source providers, and the verification waits complete with predictable jitter.
Quota and rate limits
Real numbers, by tier on catchotp. These are accurate for the canonical pricing scenario as of 2026-05; check the pricing page for current exact values.
- Free tier: 5 inboxes per minute creation rate, 5 concurrent inboxes total at any time, 1,000 messages per month inbound. Suitable for prototyping the agent loop, not for production runs.
- Pro tier ($29/mo): roughly 50 inboxes per minute creation rate, 100 concurrent inboxes, 50,000 messages per month inbound. Suitable for individual-developer agent workloads and small evaluation runs.
- Team tier ($149/mo): higher creation rate (negotiable), unlimited concurrent inboxes within fair-use, 500,000 messages per month inbound. Suitable for a team running multiple parallel agent jobs.
For the 50-signup scenario at the top of this post: a single Pro account handles it comfortably. 50 inboxes created over 60 seconds is one per ~1.2 seconds — well under the rate limit. 50 concurrent inboxes lives inside the Pro 100-concurrent ceiling. 50 messages received once per signup is a tiny fraction of the monthly cap.
If you are running this pattern repeatedly — say, evaluating different vendor segments daily — you will hit the monthly message cap before the rate limit. The math: 50 signups × 30 days = 1,500 messages plus retries. Still inside Pro. The cap matters when you scale to “evaluate every vendor in 5 categories every week.”
The upstream rate limits — Gmail / Outlook / SES on the sending side — are not catchotp’s to manage, but they shape your concurrency choices. If 30 of your 50 services use Resend or Postmark to send their verification emails, those senders’ per-recipient-domain throttles will hit before our inbound limits do. This is one reason concurrent fan-out at 50 is the upper bound for this pattern, not 500.
A complete TypeScript example
End-to-end agent runner. Reads a list of services from a JSON file, creates an inbox per service, signs up, waits for the verification email, extracts the trial key, returns the results. Uses a custom semaphore (no external dependency).
import { CatchOTP } from '@catchotp/sdk';
import { nanoid } from 'nanoid';
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
interface Service {
name: string;
signupUrl: string; // POST endpoint that takes { email }
verifyUrl: string; // POST endpoint that takes { email, code } → { trialKey }
codeFormat: 'otp' | 'magic-link';
}
interface TaskResult {
service: string;
status: 'ok' | 'error';
trialKey?: string;
error?: string;
}
class Semaphore {
private current = 0;
private waiters: Array<() => void> = [];
constructor(private max: number) {}
async acquire(): Promise<void> {
if (this.current < this.max) {
this.current++;
return;
}
return new Promise(resolve => this.waiters.push(resolve));
}
release(): void {
const next = this.waiters.shift();
if (next) next();
else this.current--;
}
}
function extractCode(message: any, format: Service['codeFormat']): string | null {
// Fallback chain
if (format === 'otp' && message.otp) return message.otp;
if (format === 'magic-link' && message.links?.length) {
const verifyLink = message.links.find((l: any) => /verify|confirm|activate/i.test(l.url));
return verifyLink?.url ?? message.links[0].url;
}
// Generic fallback: 6-digit run in the body
const m = (message.text ?? '').match(/\b(\d{4,8})\b/);
return m?.[1] ?? null;
}
async function runOne(service: Service, taskId: string, sem: Semaphore): Promise<TaskResult> {
await sem.acquire();
try {
const inbox = await otp.inboxes.create({
mode: 'ephemeral',
ttlMinutes: 30,
idempotencyKey: taskId,
});
// Trigger signup
const signupRes = await fetch(service.signupUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: inbox.address }),
});
if (!signupRes.ok) {
return { service: service.name, status: 'error', error: `Signup ${signupRes.status}` };
}
// Wait for verification
const message = await otp.messages.waitFor(inbox.id, { timeoutSeconds: 90 });
const code = extractCode(message, service.codeFormat);
if (!code) {
return { service: service.name, status: 'error', error: 'No code/link extracted' };
}
// Complete verification
const verifyRes = await fetch(service.verifyUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: inbox.address, code }),
});
if (!verifyRes.ok) {
return { service: service.name, status: 'error', error: `Verify ${verifyRes.status}` };
}
const { trialKey } = await verifyRes.json();
return { service: service.name, status: 'ok', trialKey };
} catch (err: any) {
return { service: service.name, status: 'error', error: err.message ?? String(err) };
} finally {
sem.release();
}
}
async function runAll(services: Service[]): Promise<TaskResult[]> {
const sem = new Semaphore(10); // 10 concurrent — well under Pro's create rate
const results = await Promise.all(
services.map(s => runOne(s, nanoid(), sem))
);
return results;
}
// Usage
const services: Service[] = JSON.parse(
await Bun.file('./services.json').text()
);
const results = await runAll(services);
console.log(`Successful: ${results.filter(r => r.status === 'ok').length}/${results.length}`);
for (const r of results.filter(r => r.status === 'error')) {
console.error(`${r.service}: ${r.error}`);
}
About 80 lines. The shape that matters: each task is independent, the semaphore enforces concurrency, the fallback chain handles parser misses, and a failure on one task does not abort the others. You can rerun against the failed subset — same services.json filtered to the names that errored — and the idempotency key returns the existing inbox if the create call had already succeeded.
Failure modes and recovery
Three concrete failure modes show up in this pattern, and each has a clear recovery path.
Verification email never arrives. The wait times out after 90 seconds, the task returns an error, the agent moves on. Most providers send verification within 5 seconds; a 90-second timeout is generous enough to absorb upstream delays without blocking the run. To resume: filter the result set to the timeout failures, retry only those, and if a second pass also fails the service is genuinely flaky on email delivery and the agent should report it as such.
Signup form blocks the address. Some signup forms reject the catchotp domain (or any non-Gmail/Outlook domain) outright. The error comes back as a 4xx from the signup endpoint and the task short-circuits. The right behavior is to log the service as “blocks programmable email” and move on — retry will not change the outcome.
Parser misses on a specific service. A retry on the same inbox will not help — the message is already there, the parser already failed. The right recovery is to add a service-specific override (the pattern parameter on waitForOtp) and rerun just that task. Across a 50-service evaluation we typically see this for 1 to 2 services; the override is one regex.
The umbrella recommendation: persist task results to a database between runs, key by task ID, and reissue only the failed subset on retry. The idempotency key on inboxes.create means re-runs do not waste inbox quota.
What this looks like via MCP
If your agent runs inside an MCP-aware host (Claude Desktop, Claude Code, Cursor), you can drive this entire flow from natural language without writing the orchestration code yourself. The catchotp MCP server (@catchotp/mcp) exposes create_inbox, wait_for_otp, wait_for_message, and the rest as agent-callable tools. The host runs the loop; the model decides when to call which tool.
The trade-off is determinism. A direct SDK loop like the one above is fully scripted — the same input produces the same control flow. An MCP-driven agent loop is governed by the model’s tool-call decisions, which are usually correct but not bit-for-bit reproducible. For an evaluation run where you want repeatability, the direct SDK shape is the right choice. For an exploratory task (“evaluate these 12 things and write me a report”), the MCP-driven shape is better. The two are complementary.
The MCP-config-block, scoping recommendations, and a working agent example are in the AI agent email handling post. Refer there for the agent-side specifics.
FAQ
How many inboxes can I create per minute?
The exact rate depends on tier — roughly 5 per minute on Free, 50 per minute on Pro, higher on Team. . The right way to think about it is: stay below 1 create per second on Free, 1 per 1.2 seconds on Pro, and you will not hit the limit. The semaphore pattern in the code example above keeps you safely under.
What happens if a signup verification email doesn’t arrive?
Your waitFor call returns when the timeout fires (default 90 seconds in the example above). The task returns an error, the agent moves on, and you can retry just the failed subset. Most verification emails arrive within 5 seconds, so a 90-second timeout absorbs almost all upstream delays. Persistent non-arrival usually means the signup form rejected the address, the source provider deferred delivery for spam reasons, or the service is genuinely down — none of which are recoverable by polling longer.
Can I run this from a Lambda?
Yes, with one caveat. AWS Lambda has a 15-minute maximum execution time, which is plenty for a 50-signup run that completes in 1 to 3 minutes. The catch is that Lambda Function URLs and API Gateway have their own connection-idle timeouts (typically 29 seconds for API Gateway, longer for Function URLs), which can cut your wait calls short if you go over. The fix is to keep individual waitFor timeouts under the gateway timeout, run multiple shorter waits if you need longer total budget, or run from EC2 / ECS / a long-running worker for runs that exceed Lambda comfort.
How do I clean up 50 inboxes after the run?
Two options. Pass mode: 'ephemeral' and a ttlMinutes (e.g. 30) on creation; the inboxes auto-delete after the TTL fires. No cleanup code needed. Or call otp.inboxes.delete(inboxId) explicitly at the end of each task; this releases the inbox immediately, frees the concurrent-inbox quota, and removes the entries from the dashboard. For agent runs we recommend the TTL pattern by default — explicit deletion is only needed if you are running into the concurrent-inbox cap before the next run starts.
What about cost? 50 inboxes × N runs per day adds up.
Inbox creation is not separately billed on catchotp; the metered resource is messages received, not inboxes created. 50 signups generates roughly 50 to 100 messages (one verification, sometimes a welcome email). At Pro’s 50,000-message-per-month cap, that is 500 to 1,000 runs per month before you hit overage. For most agent workloads this is well within tier; for higher volume the Team tier or metered overage applies.
Can multiple agents share one workspace?
Yes, but use scoped API keys. A persistent workspace-wide key gives every agent access to every inbox in the workspace, which is the wrong shape for any non-trivial multi-agent setup. Scoped keys (per-task or per-agent) limit blast radius and make the audit log readable. The Pro and Team tiers both support scoped keys; the Free tier exposes one workspace key intended for prototyping. The agent email handling post covers the security shape in detail.
Does this work for magic-link verification, not just OTP codes?
Yes. The fallback chain in the code example handles both cases: if the message has an otp field populated, use it; otherwise, look for verification-shaped links (/verify, /confirm, /activate in the URL); otherwise, fall through to a generic numeric extraction. Roughly 30% of services in 2026 use magic-link rather than OTP for the first verification, so this fallback is not a corner case.
What is the realistic success rate on a 50-signup run?
In our internal testing on a representative cross-section of B2B SaaS signup flows, 90% to 95% succeed on the first pass, and another 3% to 5% succeed on a single retry. The remaining 1% to 5% fail because the signup form actively blocks programmable-email domains, the service requires an enterprise-domain email, or the verification flow needs a CAPTCHA. Those are not recoverable by retry — they are signals that the service is not signup-automation-friendly.
Run this pattern. Free tier covers initial prototyping, Pro at $29/mo handles 50-signup runs comfortably. Start free or read the agent use case page for more.
Related reading
- How AI Agents Handle Email — the per-task agent loop, MCP server config, and security model.
- What is programmable email? — the create / wait / parse primitives this pattern is built on.
- The disposable email problem in 2026 — why programmable-email domains pass signup checks that disposable domains do not.
The shorter version: 50-parallel signups is a real workload, not a demo. The patterns that survive are bounded concurrency, idempotency keys, true long-poll waits, and a fallback chain on parsing. Get those right and the agent does in five minutes what would take a person a working day.