January 30, 2026

SMS verification with Playwright

Most login forms rely on SMS to deliver short-lived one-time passwords (OTPs). If that SMS never arrives or contains the wrong content, people get locked out of their accounts and support tickets start piling up.

With a repeatable test that requests a code, reads the SMS, and confirms the login succeeds, you can catch regressions in OTP generation, message content, and verification logic before they reach production. The linked demo uses Mailisk virtual SMS, so it does not exercise a mobile carrier or third-party SMS provider.

We'll pair Playwright with Mailisk's SMS testing features. We'll run an end-to-end Playwright test that:

  1. Triggers SMS-based login on a demo application.
  2. Reads the OTP from a dedicated Mailisk phone number using the Node SDK.
  3. Submits the code back in the UI and confirms the user reaches the dashboard.

If you want to follow along or view the final result, you can check out the repository.

Playwright

We'll reuse the same Playwright setup you might already have for UI tests. If you're new to Playwright, take a minute to browse the official docs; the sample project used here already has it configured.

Mailisk

For SMS we need two things from Mailisk:

  • An API key.
  • A dedicated phone number that can receive the login codes.

You can request a number directly from the Mailisk dashboard. Once it's approved you'll see it listed together with its status (you can also confirm programmatically via await mailisk.listSmsNumbers()). That number becomes the destination we hand to our test application.

Install the Node SDK:

npm install mailisk

Mailisk's Node client exposes the searchSmsMessages(phoneNumber, params?, requestOptions?) helper. By default it waits (up to five minutes) until at least one SMS matches your filters, and it ignores messages older than 15 minutes. You can tweak those behaviors with wait, requestOptions.timeout, or from_date. We'll rely on that built-in polling rather than writing our own loop.

Getting started

Create a .env file and add your Mailisk credentials:

MAILISK_API_KEY=<your-api-key>
MAILISK_SMS_NUMBER=<verified-mailisk-phone-number>

Update playwright.config.ts (or a shared setup file) so Playwright can read these values:

require("dotenv").config({ path: path.join(__dirname, "../.env") });

Writing the test

We'll keep the test in sms-verification.spec.ts. The target flow is:

  • Go to login page.
  • Enter the dedicated phone number.
  • Wait for the SMS code to land in Mailisk.
  • Parse the OTP.
  • Submit it and ensure the user ends up on the dashboard.

Avoid running multiple SMS tests in parallel against the same number unless you can reliably distinguish them by filtering the message body.

To avoid picking up old messages when running tests close together, you can also set from_date. This ensures Mailisk ignores any OTPs delivered by a previous test. We’ll do this in the example below.

import { test, expect } from "@playwright/test";
import { MailiskClient } from "mailisk";

const mailisk = new MailiskClient({ apiKey: process.env.MAILISK_API_KEY });
const smsNumber = process.env.MAILISK_SMS_NUMBER;

test.describe("SMS verification", () => {
  test("logs a user in via OTP", async ({ page }) => {
    if (!smsNumber) throw new Error("MAILISK_SMS_NUMBER not configured");

    await page.goto("http://localhost:3000/login");
    // So we ignore older messages
    const searchStartIso = new Date().toISOString();
    await page.click('button:has-text("Use SMS instead")');
    await page.fill("#phone", smsNumber);
    await page.click('form button[type="submit"]');
    await expect(page).toHaveURL("http://localhost:3000/login/verify");

    // Wait for the OTP to be delivered to our Mailisk number.
    const { data: messages } = await mailisk.searchSmsMessages(smsNumber, {
      body: "Your verification code is",
      from_date: searchStartIso,
    });

    expect(messages.length).toBeGreaterThan(0);
    const latest = messages[0];
    const otp = latest.body.match(/(\d{6})/)?.[1];
    expect(otp).toBeDefined();

    await page.fill("#code", otp ?? "");
    await page.click('form button[type="submit"]');
    await expect(page).toHaveURL("http://localhost:3000/dashboard");
  });
});

The searchSmsMessages call mirrors the example from the Mailisk docs. We scope the request with a body filter and pass the from_date captured just before triggering the SMS, which ensures we don't accidentally pick up an older OTP when tests run close together. Because the Node SDK automatically long-polls (and times out after five minutes), the test simply waits until Mailisk returns the OTP instead of retrying manually. Still, you can change the timeout or pass wait: false if you want to fail faster.

Extracting the OTP

const searchStartIso = new Date().toISOString();
const { data: messages } = await mailisk.searchSmsMessages(
  smsNumber,
  { body: "Your verification code is", from_date: searchStartIso },
  { timeout: 120000 } // optional override for the default 5 min timeout
);

const latest = messages[0];
const otp = latest.body.match(/(\d{6})/)?.[1];
expect(otp).toBeDefined();

Mailisk stores the full SMS body, so parsing the digits is as simple as running a regex. You can extend the filter with from_number if you want to filter by the sender, or adjust from_date to widen or tighten the window. Check the API reference for the full list of search parameters.

Tip: When multiple tests send OTPs to the same number, capture from_date immediately before clicking "Send code". A fast message could otherwise arrive before the timestamp and be excluded. This keeps the search scoped to the current run without missing its SMS.

Finish by submitting the OTP and optionally logging out/in again to ensure the session is fully verified. At that point you have a regression test proving that the application's virtual-SMS path, message content, and verification endpoint are behaving correctly.

Final spec

Here's the full sms-verification.spec.ts for easy reference:

import { test, expect } from "@playwright/test";
import { MailiskClient } from "mailisk";

const mailisk = new MailiskClient({ apiKey: process.env.MAILISK_API_KEY });
const smsNumber = process.env.MAILISK_SMS_NUMBER;

test.describe("SMS verification", () => {
  test("logs a user in via OTP", async ({ page }) => {
    if (!smsNumber) throw new Error("MAILISK_SMS_NUMBER not configured");

    await page.goto("http://localhost:3000/login");
    const searchStartIso = new Date().toISOString();
    await page.click('button:has-text("Use SMS instead")');
    await page.fill("#phone", smsNumber);
    await page.click('form button[type="submit"]');
    await expect(page).toHaveURL("http://localhost:3000/login/verify");

    const { data: messages } = await mailisk.searchSmsMessages(smsNumber, {
      body: "Your verification code is",
      from_date: searchStartIso,
    });

    expect(messages.length).toBeGreaterThan(0);
    const latest = messages[0];
    const otp = latest.body.match(/(\d{6})/)?.[1];
    expect(otp).toBeDefined();

    await page.fill("#code", otp ?? "");
    await page.click('form button[type="submit"]');
    await expect(page).toHaveURL("http://localhost:3000/dashboard");

    // At this point the demo pushes the user to /dashboard, which represents a
    // successful login within this example application.
  });
});

With this in place you now have a Playwright test that continuously validates the demo's virtual-SMS flow. Regressions in OTP generation, message content, Mailisk ingestion, or verification logic can be caught during CI.

result-preview

Ready to start testing emails?
Create a free account.

Get started