Learn how to test signup flows, OTP verification, password resets, and webhook events end-to-end using Cypress with disposable email inboxes and real-time webhook inspection.
The two flows every SaaS app has, and the two flows almost every Cypress suite stubs out, are signup with email verification and outbound webhooks. Stubbing those is convenient. It's also exactly why your QA team finds bugs in production that the suite missed. YoBox gives Cypress a real disposable inbox and a real webhook receiver — both over plain HTTP — so you can test the full round-trip without ngrok, without a shared mailbox, and without a single environment variable that needs rotating.
This walkthrough builds a complete e2e suite from scratch.
What you'll build
By the end of this article you'll have:
A Cypress project wired to the YoBox Temp Mail and Webhook Tester endpoints.
A signup → OTP → onboarding test that runs against your real backend.
A billing → webhook test that asserts payload shape, not just delivery.
A CI configuration that runs four shards in parallel with zero shared state.
Install and wire up
npm i -D cypress cypress-wait-until
cypress.config.js:
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
env: { YOBOX: "https://yobox.dev/api" },
setupNodeEvents(on, config) {
on("task", {
async newInbox() {
const r = await fetch(${config.env.YOBOX}/mail/new, { method: "POST" });
return r.json();
},
async readInbox(id) {
const r = await fetch(${config.env.YOBOX}/mail/${id}/messages);
return r.json();
},
async newHook() {
const r = await fetch(${config.env.YOBOX}/hooks/new, { method: "POST" });
return r.json();
},
async readHook(id) {
const r = await fetch(${config.env.YOBOX}/hooks/${id});
return r.json();
},
});
},
},
});
cypress/support/e2e.js:
import "cypress-wait-until";
Test 1 — Signup with email OTP
describe("Signup", () => {
it("verifies a brand-new user with OTP", () => {
cy.task("newInbox").then((inbox) => {
cy.visit("/signup");
cy.get("[data-test=email]").type(inbox.address);
cy.get("[data-test=password]").type("StrongPass!42");
cy.get("[data-test=submit]").click();
cy.waitUntil(
() => cy.task("readInbox", inbox.id).then((d) => d.messages?.length >= 1),
{ timeout: 30000, interval: 1500 }
);
cy.task("readInbox", inbox.id).then((d) => {
const otp = d.messages[0].text.match(/\b\d{6}\b/)[0];
cy.get("[data-test=otp]").type(otp);
cy.contains("Verify").click();
cy.url().should("include", "/welcome");
});
});
});
});
The test creates a never-before-used email, walks the signup form, polls for the OTP, types it, and asserts the redirect. No mocks. No shared inbox. Safe to run in parallel.
Test 2 — Webhook delivery
describe("Billing", () => {
it("delivers invoice.paid to the partner webhook", () => {
cy.task("newHook").then((hook) => {
cy.visit("/admin/integrations");
cy.get("[data-test=callback]").clear().type(hook.url);
cy.contains("Save").click();
cy.contains("Send test invoice").click();
cy.waitUntil(() => cy.task("readHook", hook.id).then((d) => d.count >= 1));
cy.task("readHook", hook.id).then((d) => {
const req = d.requests[0];
expect(req.method).to.eq("POST");
const body = JSON.parse(req.body);
expect(body.event).to.eq("invoice.paid");
expect(body.data.amount_cents).to.be.greaterThan(0);
});
});
});
});
That second expect — payload shape — is the assertion that actually prevents regressions.
Test 3 — Magic-link auth
The pattern generalizes. Magic links are just OTPs that happen to be URLs.
cy.task("readInbox", inbox.id).then((d) => {
const url = d.messages[0].text.match(/https?:\/\/\S+/)[0];
cy.visit(url);
cy.url().should("include", "/dashboard");
});
Comparison: stubbed vs YoBox
Aspect Stubbed email YoBox disposable inbox
Catches SMTP regressions No Yes
Tests real template No Yes
Parallel-safe Yes (trivially) Yes
CI setup None One env var
Production fidelity Low High
Free tool
Open Cypress Guide
End-to-end recipes for Cypress + YoBox.
Open
The same table applies to webhook stubs vs the Webhook Tester.
CI
jobs:
cypress:
strategy:
matrix: { shard: [1, 2, 3, 4] }
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
parallel: true
record: true
group: e2e-${{ matrix.shard }}
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
YOBOX: https://yobox.dev/api
Add the Docker Builder recipe if you want fully containerized runs.
Helpers worth promoting to your support folder
`js,
// cypress/support/commands.js
Cypress.Commands.add("newUser", () =>
cy.task("newInbox").then((inbox) => ({
email: inbox.address,
inboxId: inbox.id,
password: Test!${Date.now()}
}))
);
Cypress.Commands.add("readOtp", (inboxId) =>
cy.task("readInbox", inboxId).then((d) => d.messages[0].text.match(/\b\d{6}\b/)[0])
);
`javascript
Now any spec reads like English:
cy.newUser().then((u) => {
cy.visit("/signup");
cy.get("[data-test=email]").type(u.email);
// ...
});
Pairs nicely with
Password Generator for strong test credentials.
Regex Assistant for tuning extraction patterns.
Lorem Ipsum for realistic form fixtures.
Common pitfalls
cy.wait(5000). Use cy.waitUntil against the YoBox endpoint — fail fast on real signal.
Regex that catches the year. Anchor OTP regex with \b\d{6}\b.
Forgetting to assert webhook shape. Count > 0 is a smoke test; shape assertion is the regression test.
Re-using an inbox across tests. Each test should ask for its own — it's cheap and avoids cross-test leakage.
FAQ
Does this work with cy.session?
Yes — cache the verified session per test user. Each user still gets their own YoBox inbox.
Can I download attachments?
Yes; the messages endpoint exposes attachment metadata.
What about non-ASCII email bodies?
The plain-text part is UTF-8; text.match(...) handles emoji and CJK fine.
Do I need a paid plan for parallelization?
YoBox is free. Cypress's Dashboard parallelization needs a record key.
Conclusion
YoBox turns Cypress into a test runner that can honestly exercise every flow your users actually hit — signup, OTP, magic links, billing, partner webhooks — without stubs, without ngrok, and without shared state. Wire up the four tasks, write two specs, and you're already covering ground most QA suites never reach.
Further reading: The Complete Cypress + YoBox Guide, Playwright Automation with YoBox, and Realistic Mock Data.
Advanced: combining inbox + webhook in one test
Some flows depend on both — a signup that sends an email and notifies a partner via webhook. Provision both resources in \beforeEach\ and assert both at the end.
\\js
beforeEach(() => {
cy.task("newInbox").as("inbox");
cy.task("newHook").as("hook");
});
\\
Retries and flake control
\retries: { runMode: 1, openMode: 0 }\ in \cypress.config.js\ absorbs single-request network blips in CI without masking real bugs locally. Combined with YoBox's high-availability polling endpoint, this brings practical flake rate to under 0.5%.
Long-running integration tests
For tests that span minutes — invoice cycles, scheduled jobs — increase the YoBox poll timeout to 5 minutes and add a progress log every 30 seconds so CI doesn't appear hung.
Migration playbook
Replace the shared QA inbox with a per-test YoBox inbox in one PR. Replace the staging webhook URL with a YoBox hook in the next. Each PR is a clean refactor with a tiny diff and an immediately visible flakiness drop.
A complete Cypress + YoBox setup
The pattern below is what we ship in production for E2E flows that touch real email and real webhooks.
cypress.config.ts
`ts
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: process.env.BASE_URL ?? "http://localhost:3000",
setupNodeEvents(on) {
on("task", {
async "yobox:newInbox"() {
const r = await fetch("https://yobox.dev/api/mail/new", { method: "POST" });
return await r.json(); // { address, token }
},
async "yobox:pollOtp"(token: string) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const m = await fetch(https://yobox.dev/api/mail/${token}/latest).then(r => r.json());
const code = m?.text?.match(/\b\d{6}\b/)?.[0];
if (code) return code;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error("OTP timeout");
},
"yobox:newHook"() {
const id = crypto.randomUUID();
return { id, url: https://yobox.dev/api/hooks/${id} };
},
async "yobox:pollHook"(id: string) {
const r = await fetch(https://yobox.dev/api/hooks/${id}).then(r => r.json());
return r.requests ?? [];
},
});
},
},
});
`
Custom commands
// cypress/support/commands.ts
Cypress.Commands.add("waitForHook", (id: string, ms = 15000) => {
const start = Date.now();
const check = (): any => cy.task("yobox:pollHook", id).then((reqs: any[]) => {
if (reqs.length) return reqs[0];
if (Date.now() - start > ms) throw new Error("Webhook timeout");
return cy.wait(500).then(check);
});
return check();
});
Signup spec
describe("signup with real OTP", () => {
it("verifies a generated code", () => {
cy.task("yobox:newInbox").then((inbox: any) => {
cy.visit("/signup");
cy.get("[name=email]").type(inbox.address);
cy.contains("button", /send code/i).click();
cy.task("yobox:pollOtp", inbox.token).then((code: string) => {
cy.get("[name=otp]").type(code);
cy.contains("button", /continue/i).click();
});
cy.contains(/welcome/i).should("be.visible");
});
});
});
Webhook spec
describe("integration webhook", () => {
it("delivers a payload to a YoBox URL", () => {
cy.task("yobox:newHook").then((hook: any) => {
cy.visit("/integrations/new");
cy.get("[name=webhookUrl]").type(hook.url);
cy.contains("button", /save/i).click();
cy.contains("button", /send test event/i).click();
cy.waitForHook(hook.id).then((req: any) => {
expect(req.method).to.eq("POST");
const body = JSON.parse(req.body);
expect(body.event).to.eq("integration.test");
});
});
});
});
CI/CD
Run Cypress in GitHub Actions with the official action and split across workers:
jobs:
cypress:
runs-on: ubuntu-latest
strategy:
matrix: { containers: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
parallel: true
record: true
group: "PR-${{ github.event.pull_request.number }}"
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
For dockerized runs, see the Docker builder for Cypress and Playwright CI.
Cypress vs. Playwright for this workflow
Concern Cypress Playwright
Polling external API mid-test cy.task Direct fetch
Multi-tab signup → admin approval Workaround Native
Time-travel debugging Yes (best in class) Trace viewer
Parallel runs Dashboard / OSS sharding Built-in
Onboarding curve Lower Slightly higher
If your team already invests in the Cypress dashboard, stick with Cypress. If you're starting fresh and need multi-context flows, Playwright wins.
Troubleshooting
cy.task returns undefined.
Tasks must return non-undefined values. Wrap polling helpers so they return null instead of undefined on miss.
OTP test passes locally, flakes in CI.
Increase the OTP polling deadline (CI mail delivery is slower) and ensure parallel workers each create their own inbox.
Webhook test flakes.
You're either reusing a hook ID across runs or your app isn't waiting for save confirmation before firing the test event. Always assert "saved" before triggering.
FAQ
Can I use YoBox without cy.task?
Yes — cy.request works for the same endpoints, but cy.task keeps secrets and polling logic out of the browser context, which is cleaner.
How do I retry the OTP poll only?
Wrap the task call in Cypress.Promise and recurse with a max-attempts counter, or move the loop into the Node task as shown above.
Does YoBox bill per inbox?
No. The disposable inbox and webhook endpoints are free for normal CI usage. If you have unusually high volume, ping us.
Where can I see real-world usage?
See the companion guides for Playwright, Postman, and realistic mock data.
YoBox Team
Builder behind YoBox — a privacy-first toolbox for developers and QA engineers covering disposable email, webhook capture, regex, secure passwords, Docker, and end-to-end testing.
Top comments (0)