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.

Automating this flow is the only way to know it keeps working. With a repeatable test that requests a code, reads the SMS, and confirms the login succeeds, you can catch carrier delivery issues and verification issues before they cause problems.

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 security code",
      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 security code", 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 entire SMS payload, 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, 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 after clicking "Send code". That keeps your search scoped to the current run and prevents false positives from an earlier SMS that still sits inside the default 15-minute window.

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 SMS delivery, content, and the verification endpoint are all 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 security code",
      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 your SMS flow. Any regression in delivery, message content, or verification logic will be caught during CI instead of production.

result-preview

Ready to start testing emails?
Create a free account.

Get started