Cypress specs run inside the browser, but an HTTP client that mints inboxes and polls for mail belongs in Node — so the pattern here is a cy.task() that wraps otpbox-sdk, called from an otherwise ordinary spec. No mocked email provider, no shared inbox that two parallel spec files fight over.
cypress-plugin/otpbox-cypress package (a ready-made cy.task/command wrapper) is being built but isn't published to npm yet. This guide uses otpbox-sdk directly, which is on npm today and is all a cy.task needs.cy.request()cy.request() can call the OTPBox REST API directly and would work for a one-shot inbox creation. But waiting for an email means polling, and a cy.task() that runs in Node lets you use otpbox-sdk's waitForOtp() as-is instead of hand-rolling a retry loop in the browser-side spec. It also keeps your API key out of the browser context entirely.
npm install otpbox-sdk
cypress.config.tsimport { defineConfig } from 'cypress';
import { OTPBox } from 'otpbox-sdk';
export default defineConfig({
e2e: {
setupNodeEvents(on) {
const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
let currentInboxId: string | null = null;
on('task', {
async otpboxCreateInbox() {
const inbox = await client.createInbox();
currentInboxId = inbox.id;
return inbox; // { id, address, domain, expiresAt }
},
async otpboxWaitForOtp(inboxId: string) {
return client.waitForOtp(inboxId, { timeoutMs: 20_000 });
},
async otpboxDeleteInbox(inboxId: string) {
await client.deleteInbox(inboxId);
return null;
},
});
},
},
});
Set OTPBOX_KEY in your shell, CI secret store, or a cypress.env.json that's gitignored — never commit it. No key? otpbox-sdk has no auto-mint helper of its own; mint one with curl -X POST https://otpbox.io/api/v1/keys/free (see Get a key) and export it, or use an organization key from the dashboard for CI.
describe('sign up', () => {
it('verifies with a real OTP', () => {
cy.task('otpboxCreateInbox').then((inbox: any) => {
cy.wrap(inbox.id).as('inboxId');
cy.visit('https://your-app.example.com/signup');
cy.get('[name="email"]').type(inbox.address);
cy.get('button[type="submit"]').click();
// Blocks Node-side until the SDK sees a message with an extracted code
cy.task('otpboxWaitForOtp', inbox.id, { timeout: 25000 }).then((code) => {
expect(code).to.be.a('string');
cy.get('[name="otp"]').type(code as string);
cy.get('button[type="submit"]').click();
cy.contains('Welcome').should('be.visible');
});
});
});
afterEach(function () {
if (this.inboxId) cy.task('otpboxDeleteInbox', this.inboxId);
});
});
Two Cypress specifics worth calling out: pass a per-task timeout option to cy.task() that's longer than the SDK's own timeoutMs (Cypress's default task timeout is 60 seconds, but it's worth being explicit), and use this.inboxId/cy.wrap().as() rather than a module-level variable, since Cypress commands are asynchronous and queued rather than awaited directly.
Some signup flows email a confirmation link instead of a 6-digit code. Add one more task alongside the others, then visit the URL it returns:
// cypress.config.ts, inside setupNodeEvents
on('task', {
// ...otpboxCreateInbox, otpboxDeleteInbox as above...
async otpboxWaitForLink(inboxId: string) {
const message = await client.waitForEmail(inboxId, { timeoutMs: 20_000 });
if (!message) return null;
const full = await client.getMessage(message.id);
return full.link?.url ?? null;
},
});
cy.task('otpboxWaitForLink', inbox.id).then((url: any) => {
expect(url).to.be.a('string');
cy.visit(url);
cy.contains('Verified').should('be.visible');
});
Once you've written the same cy.task('otpboxCreateInbox') / cy.task('otpboxWaitForOtp', ...) pair in more than one spec, it's worth promoting them to custom commands in cypress/support/commands.ts so specs read like plain English:
Cypress.Commands.add('otpboxSignUp', (email: string) => {
cy.get('[name="email"]').type(email);
cy.get('button[type="submit"]').click();
});
Cypress.Commands.add('otpboxEnterCode', (inboxId: string) => {
cy.task('otpboxWaitForOtp', inboxId, { timeout: 25000 }).then((code: any) => {
cy.get('[name="otp"]').type(code);
cy.get('button[type="submit"]').click();
});
});
This is a thin convenience layer over the same two tasks — nothing about the OTPBox side changes, it's purely to keep spec files readable once several tests need the same signup-and-verify sequence.
OTPBOX_KEY as a masked/protected CI variable — see CI/CD for GitHub Actions, GitLab CI, Jenkins and CircleCI examples (the same variable-injection steps apply regardless of which test runner consumes the key).429 quota_exceeded.otpboxCreateInbox call with an idempotency key if a flaky CI network makes your task retry — see Idempotency keys — by threading an idempotencyKey option through to client.createInbox()'s second argument.afterEach/after hook as shown above; anything left behind is cleaned up by the 15-minute expiry cron regardless.Ready to try it against your own app? Create a free account
← Back to OTPBox