Email verification is the wall that Selenium test suites hit every time. You can automate the browser perfectly — form fill, submit, redirect — but the moment your app sends a verification email, you need a real, readable inbox to continue. Most teams solve this badly: a shared Gmail account, a hardcoded test address that everyone uses, or worse, skipping the email step entirely and calling it "tested."
This guide shows you how to do it properly using FreeCustom.Email, the only disposable email provider with an official CLI, official SDKs, real-time WebSocket delivery, and an OTP extraction endpoint built specifically for this use case. No regex. No shared state. No flakiness.
Why Shared Inboxes Destroy Selenium Tests
Before writing any code, it is worth understanding exactly why the common approaches fail at scale:
Approach | Why it breaks in Selenium |
|---|---|
Shared Gmail account | Parallel tests receive each other's OTPs; wrong code entered; test fails non-deterministically |
Hardcoded temp mail URL | No API access; requires a second browser session to fetch the email |
| Requires credential management; OAuth rotation; no OTP parsing |
Mocking email in the app | Does not test the actual email delivery pipeline; misses real sending failures |
Polling a basic REST API | Slow; rate-limited; requires regex; breaks when email format changes |
Manual test execution | Scales to zero; dies in CI |
The correct solution is fresh isolated inboxes per test, real-time delivery, and automatic OTP extraction. That is exactly what the FreeCustom.Email API provides.
Architecture Overview
Here is the pattern that works reliably at any scale:
1. Before test: create a fresh disposable inbox via FCE API or CLI
2. Selenium: fill the signup form with that inbox address
3. FCE API: poll or wait for the OTP (< 200ms via WebSocket)
4. Selenium: fill the OTP field and submit
5. After test: clean up the inboxEach test gets its own inbox. Parallel tests never interfere. The inbox is disposable so there is no cleanup debt.
Setup
Get an API key
Register at freecustom.email and run:
bash
fce loginThis opens your browser, authenticates, and saves your API key to your OS keychain automatically. For CI, grab the key from the dashboard and set it as FCE_API_KEY.
Install the CLI for local use and script-based approaches:
bash
# macOS / Linux
curl -fsSL freecustom.email/install.sh | sh
# Homebrew
brew tap DishIs/homebrew-tap && brew install fce
# npm
npm install -g fcemail@latest
# Windows (Scoop)
scoop bucket add fce https://github.com/DishIs/scoop-bucket && scoop install fce
# Windows (Chocolatey)
choco install fceLatest releases: github.com/DishIs/fce-cli/releases
For SDK-based tests, install the language package:
bash
npm install freecustom-email # Node.js
pip install freecustom-email # PythonPython + Selenium + FCE API
This is the most common setup — Python Selenium with pytest. The FreeCustom.Email Python SDK handles inbox creation, waiting, and OTP extraction.
Install dependencies
bash
pip install selenium pytest pytest-asyncio freecustom-emailTest: complete signup verification flow
python
# tests/test_signup_selenium.py
import asyncio
import os
import pytest
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from freecustom_email import FreeCustomEmail
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
APP_URL = os.environ.get("APP_URL", "https://staging.myapp.com")
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
drv = webdriver.Chrome(options=options)
yield drv
drv.quit()
@pytest.fixture
def inbox():
"""Fresh inbox per test — never reuse."""
addr = f"selenium-{int(time.time())}@ditapi.info"
asyncio.run(fce.inboxes.register(addr))
yield addr
asyncio.run(fce.inboxes.unregister(addr))
@pytest.mark.asyncio
async def test_signup_and_verify(driver, inbox):
wait = WebDriverWait(driver, 15)
# Step 1: Fill signup form with the temp inbox address
driver.get(f"{APP_URL}/signup")
wait.until(EC.presence_of_element_located((By.ID, "email"))).send_keys(inbox)
driver.find_element(By.ID, "password").send_keys("TestPass123!")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
# Step 2: Wait for verification screen
wait.until(EC.presence_of_element_located((By.ID, "otp-input")))
# Step 3: Get OTP — SDK handles waiting, no polling loop needed
otp = await fce.otp.wait_for(inbox, timeout=30)
assert otp is not None, f"No OTP received for {inbox}"
assert otp.isdigit(), f"Unexpected OTP format: {otp}"
# Step 4: Fill OTP and submit
driver.find_element(By.ID, "otp-input").send_keys(otp)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
# Step 5: Assert success
wait.until(EC.url_contains("/dashboard"))
assert "/dashboard" in driver.current_url, "Verification did not redirect to dashboard"Parallel tests with pytest-xdist
Because each test creates its own inbox, parallel execution works out of the box:
bash
pip install pytest-xdist
pytest -n 4 tests/test_signup_selenium.pyFour browser sessions, four isolated inboxes, zero interference.
JavaScript + Selenium WebDriver + FCE SDK
For Node.js teams using selenium-webdriver:
bash
npm install selenium-webdriver freecustom-emailtypescript
// tests/signup.test.ts
import { Builder, By, until } from 'selenium-webdriver';
import { FreecustomEmailClient } from 'freecustom-email';
import { describe, it, before, after } from 'mocha';
import assert from 'assert';
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
const APP_URL = process.env.APP_URL ?? 'https://staging.myapp.com';
describe('Signup verification flow', () => {
let driver: any;
let inbox: string;
before(async () => {
driver = await new Builder().forBrowser('chrome').build();
inbox = `selenium-${Date.now()}@ditapi.info`;
await fce.inboxes.register(inbox);
});
after(async () => {
await driver.quit();
await fce.inboxes.unregister(inbox);
});
it('completes signup and email verification', async () => {
// Fill signup form
await driver.get(`${APP_URL}/signup`);
await driver.wait(until.elementLocated(By.id('email')), 10_000);
await driver.findElement(By.id('email')).sendKeys(inbox);
await driver.findElement(By.id('password')).sendKeys('TestPass123!');
await driver.findElement(By.css("button[type='submit']")).click();
// Wait for OTP screen
await driver.wait(until.elementLocated(By.id('otp-input')), 10_000);
// Extract OTP — no polling, no regex
const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
assert.ok(otp, 'OTP not received');
assert.match(otp, /^\d+$/, 'OTP is not numeric');
// Submit OTP
await driver.findElement(By.id('otp-input')).sendKeys(otp);
await driver.findElement(By.css("button[type='submit']")).click();
// Assert dashboard redirect
await driver.wait(until.urlContains('/dashboard'), 10_000);
});
});Java + Selenium + FCE REST API
Java does not have an official SDK yet, so call the REST API directly with any HTTP client:
java
// src/test/java/SignupVerificationTest.java
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.*;
import org.openqa.selenium.support.ui.*;
import org.junit.jupiter.api.*;
import java.net.http.*;
import java.net.URI;
import java.time.*;
import org.json.*;
import static org.junit.jupiter.api.Assertions.*;
public class SignupVerificationTest {
private WebDriver driver;
private String inbox;
private static final String FCE_KEY = System.getenv("FCE_API_KEY");
private static final String APP_URL = System.getenv("APP_URL");
private static final String FCE_BASE = "https://api2.freecustom.email/v1";
private static final HttpClient http = HttpClient.newHttpClient();
@BeforeEach
void setUp() throws Exception {
ChromeOptions opts = new ChromeOptions();
opts.addArguments("--headless", "--no-sandbox");
driver = new ChromeDriver(opts);
inbox = "selenium-" + System.currentTimeMillis() + "@ditapi.info";
registerInbox(inbox);
}
@AfterEach
void tearDown() throws Exception {
if (driver != null) driver.quit();
deleteInbox(inbox);
}
@Test
void testSignupAndVerification() throws Exception {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(15));
// Fill signup form
driver.get(APP_URL + "/signup");
wait.until(ExpectedConditions.presenceOfElementLocated(By.id("email")))
.sendKeys(inbox);
driver.findElement(By.id("password")).sendKeys("TestPass123!");
driver.findElement(By.cssSelector("button[type='submit']")).click();
// Wait for OTP screen
wait.until(ExpectedConditions.presenceOfElementLocated(By.id("otp-input")));
// Poll FCE API for OTP (max 30 seconds)
String otp = waitForOtp(inbox, 30);
assertNotNull(otp, "OTP not received within timeout");
assertTrue(otp.matches("\\d+"), "OTP is not numeric: " + otp);
// Submit OTP
driver.findElement(By.id("otp-input")).sendKeys(otp);
driver.findElement(By.cssSelector("button[type='submit']")).click();
// Assert dashboard redirect
wait.until(ExpectedConditions.urlContains("/dashboard"));
assertTrue(driver.getCurrentUrl().contains("/dashboard"));
}
private void registerInbox(String addr) throws Exception {
var body = HttpRequest.BodyPublishers.ofString("{\"inbox\":\"" + addr + "\"}");
var req = HttpRequest.newBuilder()
.uri(URI.create(FCE_BASE + "/inboxes"))
.header("Authorization", "Bearer " + FCE_KEY)
.header("Content-Type", "application/json")
.POST(body).build();
http.send(req, HttpResponse.BodyHandlers.ofString());
}
private void deleteInbox(String addr) throws Exception {
var req = HttpRequest.newBuilder()
.uri(URI.create(FCE_BASE + "/inboxes/" + addr))
.header("Authorization", "Bearer " + FCE_KEY)
.DELETE().build();
http.send(req, HttpResponse.BodyHandlers.ofString());
}
private String waitForOtp(String addr, int timeoutSecs) throws Exception {
long deadline = System.currentTimeMillis() + timeoutSecs * 1000L;
while (System.currentTimeMillis() < deadline) {
var req = HttpRequest.newBuilder()
.uri(URI.create(FCE_BASE + "/inboxes/" + addr + "/otp"))
.header("Authorization", "Bearer " + FCE_KEY)
.GET().build();
var res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) {
var json = new JSONObject(res.body());
if (json.optBoolean("success") && json.has("data")) {
return json.getJSONObject("data").optString("otp");
}
}
Thread.sleep(2000);
}
return null;
}
}Password Reset Flow with Selenium
The same pattern works for password reset:
python
@pytest.mark.asyncio
async def test_password_reset(driver, inbox):
wait = WebDriverWait(driver, 15)
# First create and verify an account
driver.get(f"{APP_URL}/signup")
wait.until(EC.presence_of_element_located((By.ID, "email"))).send_keys(inbox)
driver.find_element(By.ID, "password").send_keys("OriginalPass123!")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
wait.until(EC.presence_of_element_located((By.ID, "otp-input")))
signup_otp = await fce.otp.wait_for(inbox, timeout=30)
driver.find_element(By.ID, "otp-input").send_keys(signup_otp)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
wait.until(EC.url_contains("/dashboard"))
# Now test password reset
driver.get(f"{APP_URL}/forgot-password")
wait.until(EC.presence_of_element_located((By.ID, "email"))).send_keys(inbox)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
wait.until(EC.presence_of_element_located((By.ID, "reset-otp")))
# Same inbox, new OTP — fce.otp.wait_for always gets the latest
reset_otp = await fce.otp.wait_for(inbox, timeout=30)
driver.find_element(By.ID, "reset-otp").send_keys(reset_otp)
driver.find_element(By.ID, "new-password").send_keys("NewPass456!")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
wait.until(EC.url_contains("/login"))GitHub Actions: Full CI Pipeline
yaml
# .github/workflows/selenium-e2e.yml
name: Selenium E2E
on: [push, pull_request]
jobs:
selenium:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Chrome
run: |
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
sudo apt-get update && sudo apt-get install -y google-chrome-stable
- name: Install dependencies
run: pip install selenium pytest pytest-asyncio pytest-xdist freecustom-email
- name: Run Selenium tests (4 parallel)
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
APP_URL: ${{ vars.STAGING_URL }}
run: pytest -n 4 tests/ -v --tb=shortOTP via CLI in Shell-Based Selenium Setups
If you are running Selenium from shell scripts rather than a test framework, the fce CLI is simpler than the API:
bash
#!/usr/bin/env bash
set -euo pipefail
# Create inbox
INBOX=$(fce inbox add random | tr -d '[:space:]')
echo "Test inbox: $INBOX"
# Trigger signup via your app (Selenium would do this in the browser)
# ... selenium or curl triggers signup ...
# Extract OTP
OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
echo "OTP: $OTP"
# Continue Selenium test with the OTP
# ... selenium fills OTP field ...
# Cleanup
fce inbox remove "$INBOX"More on CLI-based CI automation: How to Automate Email Verification Testing in CI/CD Pipelines (2026)
Handling Real-World Edge Cases
Custom domains to bypass disposable email blockers
Some apps reject known disposable email domains. On the Growth plan, you can register and verify your own domain:
python
# Use your own domain for test inboxes
inbox = f"selenium-{int(time.time())}@tests.yourdomain.com"
await fce.inboxes.register(inbox)See Private or Custom Domains for setup instructions.
Verification links instead of numeric OTPs
If your app sends a magic link rather than a code, the FCE API returns it in the verification_link field:
python
# Python SDK — get the full message
messages = await fce.messages.list(inbox)
if messages:
msg = await fce.messages.get(inbox, messages[0].id)
link = msg.verification_link # full URL ready to navigate
driver.get(link)Slow staging environments
Add a retry loop or increase the SDK timeout:
python
otp = await fce.otp.wait_for(inbox, timeout=60) # 60 second timeoutComparison: FCE vs Common Alternatives for Selenium Testing
Approach | Fresh inbox per test | OTP extraction | Parallel safe | Real-time | CI-ready |
|---|---|---|---|---|---|
FreeCustom.Email API | ✓ | ✓ Auto | ✓ | ✓ WebSocket | ✓ |
Mailinator API | ✓ | ✗ Manual | ✓ | ✗ Polling | Partial |
Guerrilla Mail API | ✗ | ✗ Manual | ✗ | ✗ | ✗ |
Gmail + IMAP | Partial | ✗ Manual | ✗ | ✗ | ✗ |
Mocked email | N/A | N/A | ✓ | N/A | ✓ but untrue |
Frequently Asked Questions
Can I use fce watch WebSocket inside a Selenium test instead of polling? Yes, on Startup plan+. Use the SDK's client.realtime interface or the CLI's fce watch in a subprocess. For most Selenium tests, client.otp.waitFor() is simpler since it handles the wait internally.
Does the fresh inbox approach work with Selenium Grid? Yes. Each node creates its own inbox via the API. Inboxes are server-side resources — no local state is shared across nodes.
How fast is OTP delivery in practice? Under 200ms from the time the email hits the SMTP server. In practice with staging apps that have slight delays, you should set a 15–30 second timeout to account for the app's email sending latency, not FCE's delivery latency.
What if my app only allows work email addresses? Use a custom domain on Growth plan — your test inboxes will look like user@yourcompany.com. See Getting Around 'Work Email' Requirements Ethically (2026).
Is there a rate limit on inbox creation? Yes, per-plan. Growth allows 50 req/sec, 2M/month — more than enough for even very large Selenium suites. See freecustom.email/api/pricing for details.
Can I run this against a local dev server (localhost)? The FCE API works from anywhere. Your Selenium tests point at any URL — localhost, staging, or production. FCE receives emails independently of your test runner's network.
Do I need the CLI installed to use this with Selenium? No. The CLI is optional. The Python and JS SDKs talk directly to the REST API. The CLI is useful for shell-based scripts or quick local testing.
Related reading
Written by
Dishant Singh
A full stack developer with good knowledge of email server, SEO, proxies, and networking, have more than 3 years of experience in building webapps for the netizens. Developing open source, fast, and free SaaS for all.
Frequently Asked Questions
Does the fresh inbox approach work with Selenium Grid?+
Yes. Each node creates its own inbox via the API. Inboxes are server-side resources — no local state is shared across nodes.
How fast is OTP delivery in practice?+
Under 200ms from the time the email hits the SMTP server. In practice with staging apps that have slight delays, you should set a 15–30 second timeout to account for the app's email sending latency, not FCE's delivery latency.
What if my app only allows work email addresses?+
Use a custom domain on Growth plan — your test inboxes will look like user@yourcompany.com. See Getting Around 'Work Email' Requirements Ethically (2026).
Is there a rate limit on inbox creation?+
Yes, per-plan. Growth allows 50 req/sec, 2M/month — more than enough for even very large Selenium suites. See freecustom.email/api/pricing for details.
Can I run this against a local dev server (localhost)?+
The FCE API works from anywhere. Your Selenium tests point at any URL — localhost, staging, or production. FCE receives emails independently of your test runner's network.
Do I need the CLI installed to use this with Selenium?+
No. The CLI is optional. The Python and JS SDKs talk directly to the REST API. The CLI is useful for shell-based scripts or quick local testing.
No comments yet. Be the first to share your thoughts.