How to validate phone numbers on Webflow forms with Webflow Cloud

How to validate phone numbers on Webflow forms with a lookup API and Webflow Cloud

Learn how to build a Next.js Route Handler on Webflow Cloud that checks each phone number against a lookup API before the form submits.

How to validate phone numbers on Webflow forms with a lookup API and Webflow Cloud

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

A single POST route on Webflow Cloud can check every phone number against a lookup API before allowing the submission to continue, so sales reps stop dialing typos.

A pattern attribute on a tel input catches a missing digit. It cannot tell you whether the number is one a carrier could actually assign. Sales teams learn this one dead dial at a time, after the lead has already been scored and routed.

A lookup API closes the gap behind your own endpoint. In this guide, we’ll build a small Next.js app on Webflow Cloud that exposes one POST route. The route checks the number's format and consults a phone validation provider using a key that never reaches the browser.

What do you need to add API-based phone validation to a form in Webflow?

You need these six items before you start:

  • Webflow form: A site with a form that includes a phone input, published to a test URL or ready to be.
  • Next.js app on Webflow Cloud: A connected project meeting the bring-your-own-app requirements, including Next.js 15 or higher, for deployment on Webflow Cloud.
  • npm: The same page states it plainly: "Currently, Webflow Cloud supports only the npm package manager." Steps written with pnpm or yarn do not work here.
  • Provider account: A phone validation provider with a lookup endpoint over HTTPS, plus its API key and the reference page for that endpoint.
  • Permissions: Access to set environment variables in the Webflow Cloud project and to publish the Webflow site, since the build touches both.
  • Form origins: The exact test and production origins your form is served from, scheme included, because the route compares them as strings.

With these pieces ready, the build can connect the form to the protected lookup route. The examples use /tools as the mount path.

5 steps to add API-based phone validation to Webflow forms in Webflow Cloud

Five steps connect the phone field to one POST Route Handler, protect the provider key, mount the endpoint correctly, intercept submissions, and verify the complete validation flow.

The build moves from field identifiers through route and environment configuration to the page script and round-trip testing.

1. Set identifiers for the phone field and the form

Make sure the phone input has the name phone and the form element has the ID lead-form, because the script addresses both by those exact strings. Configure the input as a phone input so supported mobile keyboards can show a dial pad.

The number that reaches the route is whatever the visitor typed, so set the placeholder to an international example such as +14155550123. The route strips spaces, dots, parentheses, and hyphens but insists on a leading plus sign.

I would rather ask for the plus than guess a country, because guessing a default country is how a valid UK number gets rejected as a malformed US one. If your audience is domestic only, a small prefix hint in the label costs nothing and saves a support ticket.

Publish to a test URL and inspect the input in the browser. You should see name="phone" on the input and id="lead-form" on the form; if either is missing, the intercept script will exit early without applying phone validation.

2. Create the validation Route Handler in your Next.js app

Add app/api/validate-phone/route.ts to the Next.js project connected to your Webflow Cloud project as a plain Route Handler, which runs on Cloudflare Workers here as written.

The route does four things in a fixed order: reject requests whose Origin header does not match the allowed origin, pull the number from the JSON body and strip formatting characters, test it against the international E.164 format with a plus sign and country code, and only then spend a provider call. The origin check stops browsers on other sites from using your route.

Non-browser clients can spoof the Origin header, so use a provider spend cap or a rate limit on the route for abuse protection. The E.164 test turns away obvious typos before they cost a lookup.

Paste this into app/api/validate-phone/route.ts:

// app/api/validate-phone/route.ts
import { NextResponse } from 'next/server';

const E164 = /^\+[1-9]\d{1,14}$/;

type Verdict = { valid: boolean; reason?: string };

function reply(body: Verdict, status = 200) {
  return NextResponse.json(body, { status });
}

// PROVIDER EDIT 1: map your provider's response to a verdict.
// Return true or false only when the provider actually answered. Return null when the verdict
// field is missing, so an auth or quota error sent with HTTP 200 fails open instead of closed.
function isValid(data: unknown): boolean | null {
  const valid = (data as { valid?: unknown } | null)?.valid;
  return typeof valid === 'boolean' ? valid : null;
}

export async function POST(request: Request) {
  const origin = request.headers.get('origin');
  if (!process.env.ALLOWED_ORIGIN || origin !== process.env.ALLOWED_ORIGIN) {
    return reply({ valid: false, reason: 'forbidden' }, 403);
  }

  let phone = '';
  try {
    const body = (await request.json()) as { phone?: string };
    phone = String(body.phone ?? '').replace(/[\s().-]/g, '');
  } catch {
    return reply({ valid: false, reason: 'bad_request' }, 400);
  }

  if (!E164.test(phone)) {
    return reply({ valid: false, reason: 'format' });
  }

  const apiUrl = process.env.PHONE_VALIDATION_API_URL;
  const apiKey = process.env.PHONE_VALIDATION_API_KEY;
  if (!apiUrl || !apiKey) {
    return reply({ valid: false, reason: 'not_configured' }, 500);
  }

  try {
    const url = new URL(apiUrl);
    // PROVIDER EDIT 2: the query parameter name from your provider's reference.
    url.searchParams.set('number', phone);
    const upstream = await fetch(url, {
      // PROVIDER EDIT 3: the auth scheme from your provider's reference.
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: AbortSignal.timeout(4000), // a hung provider falls back instead of stalling the form
    });
    if (!upstream.ok) {
      return reply({ valid: false, reason: 'upstream' }, 502);
    }
    const verdict = isValid(await upstream.json());
    if (verdict === null) {
      return reply({ valid: false, reason: 'upstream' }, 502);
    }
    return verdict ? reply({ valid: true }) : reply({ valid: false, reason: 'invalid' });
  } catch {
    return reply({ valid: false, reason: 'upstream' }, 502);
  }
}

The three PROVIDER EDIT points are where real providers differ, and some differ in shape, not just names. Twilio Lookup puts the number in the URL path and uses HTTP Basic authentication; Veriphone names the verdict phone_valid; Abstract nests it under phone_validation.is_valid.

Why prefer headers for provider API keys

Some providers only accept the key as a query parameter, where it lands in access logs, so prefer one that takes it in a header. And some return HTTP 200 for a bad key or an exhausted quota, which is why isValid returns null rather than false when the verdict field is missing: that case falls open like an outage instead of telling every visitor their number is wrong.

You now have a route that answers either { valid: true } or { valid: false, reason }. Two reasons, format and invalid, describe the number itself and are the ones the browser blocks on.

The other four (forbidden, bad_request, not_configured, upstream) describe configuration or availability, and the page script lets those submissions through to avoid losing a lead during a vendor outage.

Test locally with ALLOWED_ORIGIN=http://localhost:3000 in .env.local, run npm run dev, and POST {"phone":"hello"} with curl carrying an Origin: http://localhost:3000 header; the response should be {"valid":false,"reason":"format"}.

3. Set the environment variables in Webflow Cloud

Values are set per environment, so test and production forms served from different domains each need their own ALLOWED_ORIGIN value. Add three variables to the Webflow Cloud environment your app deploys to, and mark the API key as secret.

Webflow Cloud exposes both secret and non-secret variables to the build and to the deployed app at runtime, and it redacts secrets from build logs, so the provider key does not appear there.

Set these in each environment that serves the form:

Variable Value Secret
PHONE_VALIDATION_API_URL The lookup endpoint URL from your provider's reference, without query parameters No
PHONE_VALIDATION_API_KEY The API key issued by your provider Yes
ALLOWED_ORIGIN The origin the form is served from, for example https://www.example.com No
Variable → Value → Secret
PHONE_VALIDATION_API_URL
The lookup endpoint URL from your provider's reference, without query parameters
No
PHONE_VALIDATION_API_KEY
The API key issued by your provider
Yes
ALLOWED_ORIGIN
The origin the form is served from, for example https://www.example.com
No

The ALLOWED_ORIGIN value must match the browser's Origin header exactly and contain only the scheme and host. Include https://, and do not append a trailing slash or any path. Browsers send the Origin header in that exact format, and the route compares it with strict equality. Once the variables are saved, redeploy: a changed value reaches the app only after a deploy.

When the deployment finishes, request https://your-staging-domain/<mount path>/api/validate-phone with a bare curl POST and no Origin header. A 403 with {"valid":false,"reason":"forbidden"} is the outcome you want; it proves the route is live at the mount path and that the origin gate is working before any browser touches it.

4. Configure the intercept script for the published page

Registering the submit listener in the capture phase on document makes it run before listeners attached to the form in later event phases. In the page-delivery setup you use, load the script near the end of the document body and set MOUNT_PATH to the path your app is mounted on.

Because the provider call is asynchronous, the script always stops the first submit, asks the route, and then calls requestSubmit() to fire a second submit event. A data-phone-checked flag on the form tells the listener to step aside on that second pass so the existing submission flow can continue.

Edit MOUNT_PATH, then paste:

<script>
(function () {
  var MOUNT_PATH = '/tools'; // the path your Webflow Cloud app is mounted on
  var form = document.getElementById('lead-form');
  var input = form ? form.querySelector('input[name="phone"]') : null;
  if (!form || !input) return;

  var error = document.createElement('div');
  error.setAttribute('role', 'alert');
  error.style.display = 'none';
  input.insertAdjacentElement('afterend', error);

  function showError(message) {
    error.textContent = message;
    error.style.display = 'block';
  }

  function clearError() {
    error.textContent = '';
    error.style.display = 'none';
  }

  function passThrough(reason) {
    if (reason) console.warn('Phone check skipped:', reason);
    form.dataset.phoneChecked = 'true';
    form.requestSubmit();
  }

  document.addEventListener('submit', function (event) {
    if (event.target !== form) return;

    if (form.dataset.phoneChecked === 'true') {
      form.dataset.phoneChecked = '';
      return; // second pass: allow other submission handling to continue
    }

    event.preventDefault();
    event.stopImmediatePropagation();
    clearError();

    fetch(MOUNT_PATH + '/api/validate-phone', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ phone: input.value })
    })
      .then(function (response) { return response.json(); })
      .then(function (data) {
        if (data.reason === 'format') {
          showError('Enter the number in international format, like +14155550123.');
          return;
        }
        if (data.reason === 'invalid') {
          showError('That number does not look valid. Check it and try again.');
          return;
        }
        passThrough(data.reason);
      })
      .catch(function () {
        passThrough('network');
      });
  }, true);
})();
</script>

The page now holds a listener that blocks on exactly two verdicts and waves everything else through with a console warning. Style the injected div through a class if you prefer, or give it the same look as the form's other error states; the role="alert" attribute means screen readers announce the message without extra work. Publish the site, type hello into the phone field, and submit.

You should see the format message appear under the input, and the submission should not complete; type a real number in E.164 format and the configured submission flow should continue.

5. Test the round trip on the published site

The response body from the route identifies failures more precisely than the form's visible state. I do this in a test environment first, then repeat the valid case once on production, since the only thing that changes between the two is ALLOWED_ORIGIN, and that is the variable most likely to be wrong.

Test these four cases:

  • Malformed input: Submit 555-1234 and expect a 200 with reason: "format", the inline message, and no second submit event fired by the script.
  • Well-formed but invalid: Submit a plus-prefixed number of the right length that no carrier can assign, such as one with an unallocated area code, and expect reason: "invalid" from the provider, surfaced as the second message.
  • Valid number: Submit a number you can answer, expect {"valid":true}, then confirm the submission arrives wherever your form submissions land.
  • Provider unavailable: Temporarily blank the API key in the test environment, update the deployment, submit a valid number, and expect a console warning plus a submission that proceeds.

These checks now cover malformed, invalid, valid, and temporarily unverifiable numbers across the complete submission flow. After the fourth check, restore the key and redeploy, then rerun the valid case to confirm the route is calling the provider again.

Every validation request also counts toward the site's monthly Webflow Cloud usage allowance, listed on Webflow's pricing page, so a form that receives thousands of submissions a day deserves a look at that allowance before launch. Before launch, also set a provider spend cap or apply a rate limit on the route.

The gate filters mistakes from visitors running JavaScript. Visitors with JavaScript disabled and bots posting straight to the form endpoint skip it, so the gate provides no hard server-side guarantee.

When all four cases behave as listed, the form catches typos and impossible numbers from real visitors, and it allows the existing submission flow to proceed rather than blocking the form when the provider has a bad day.

What causes API phone validation to fail on Webflow Cloud?

A 404 points to a missing mount path, while a 403 in only one environment points to an origin mismatch. Other common symptoms include a build broken by the edge runtime directive and submissions passing unchecked because the script never intercepted.

The deploy log and route response separate those failures into a failing deploy, a 404 on the route, a 403 in only one environment, or junk numbers arriving in your submissions.

The Webflow Cloud build fails on a route that runs locally

Cause: A route carrying export const runtime = 'edge' fails during the Webflow Cloud build. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target.

The line usually gets added because the bring-your-own-app page in Webflow's developer docs also tells you to add the edge runtime directive to API routes.

The word "edge" is doing two jobs on that page. Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the page describes it this way: "Webflow Cloud deploys your app using the Edge runtime." This allows "fast, globally distributed hosting."

The Next.js edge runtime target is a different thing: a per-route compilation mode that OpenNext cannot build. A developer who reads the platform sentence and then follows the directive instruction is doing what the docs say and shipping a broken build.

Fix: Delete the line from route.ts and from any other file it was copied into, then redeploy. If the build still fails, search the whole project for runtime = 'edge' and remove any copies.

The browser gets a 404 from /api/validate-phone

Cause: A request sent to the site root produces this 404. A Webflow Cloud app mounts at a path on your domain, and a route defined at app/api/validate-phone/route.ts is served only at <mount path>/api/validate-phone.

The script on the Webflow page has no way to learn that path on its own, which is why MOUNT_PATH is a literal you edit. The same 404 shows up inside the app when a client component fetches its own route with a root-relative URL: Webflow Cloud injects the base path at build time, and client-side fetch calls are not rewritten to include it.

The bring-your-own-app page states this directly: "Client-side fetch calls must manually include the base path to reach your endpoints correctly."

Fix: On the Webflow page, set MOUNT_PATH to the path the app is mounted on, with a leading slash and no trailing slash, and republish. Inside the app, use NEXT_PUBLIC_BASE_PATH in the environment as the mount path source and prefix client fetches with it.

Confirm by opening the full route URL in a browser tab: a GET will typically return a 405 because the handler exports only POST; any response other than 404 shows the path resolves.

The route returns 403 in one environment but works in another

Cause: A 403 isolated to one environment usually means its ALLOWED_ORIGIN value differs from the scheme-and-host format required by the route's strict equality check.

Check the affected environment first. Requests with no Origin header at all, from curl or server-side scripts, are rejected on purpose. Keep a provider spend cap or route rate limit in place because non-browser clients can spoof the Origin header.

Fix: Correct the affected environment's value so it contains only the scheme and host, including https:// and no trailing slash or path. Redeploy and read the response body. {"valid":false,"reason":"forbidden"} confirms the origin gate fired rather than a format failure.

For local development, set ALLOWED_ORIGIN=http://localhost:3000 in .env.local, run npm run dev, and send curl an Origin: http://localhost:3000 header.

If one form is served from several domains, store them comma-separated, split the variable in the route, and check includes() instead of strict equality.

Every submission goes through, including obviously invalid numbers

Cause: The script may have missed the submit, or it may have deliberately allowed the submission to continue. A listener added with form.addEventListener('submit', ...) in the bubbling phase may not run early enough to stop other submission listeners.

The capture-phase listener on document exists to run before listeners attached to the form in later event phases, so if someone simplified it to a plain form listener, the check may be skipped.

The other path is by design: every reason except format and invalid passes the submission through. A missing PHONE_VALIDATION_API_KEY in that environment returns not_configured, a provider error returns upstream, and both are treated as "do not lose the lead."

Fix: Open the browser console and submit a junk number. No POST to the route means the listener is not intercepting: restore document.addEventListener('submit', handler, true) and confirm the form ID and input name match lead-form and phone.

A POST followed by Phone check skipped: not_configured means the variables are not set in the environment the page is hitting. Phone check skipped: upstream means the provider rejected the call; compare the parameter name and auth header against your provider's reference, since those are the two lines that vary between services.

What you can build next with phone validation and Webflow

Once you confirm a number is real and assignable before it lands in anyone's inbox, you can extend the same shape across the rest of the lead record, then hand the checked lead to your CRM the same way you do in Webflow forms to HubSpot.

Webflow's REST API and a CMS collection provide the building blocks for connecting the same app with structured lead content. For environments and the CMS APIs, Webflow's developer docs cover the next layer.

Frequently asked questions

Can one script validate phone fields on several forms on the same page?

Yes. Give each form a distinct ID and collect the IDs in an array. For each form, find its own input[name="phone"], error element, and submission state, then compare event.target with that form. Keep a separate data-phone-checked flag per form so one form's successful second submit cannot bypass validation on another.

Can I use Astro or Vite instead of Next.js for the validation route?

Astro, yes; Vite, not as documented. An Astro 6 or 7 app on Webflow Cloud can serve an endpoint that preserves the same request and response contract, reading the provider key from locals.runtime.env rather than process.env. Webflow documents Vite apps as supported but describes no server endpoints for them. Whichever framework serves it, the endpoint must read the Origin header, normalize and test the phone number, call the provider with an environment variable, and return the existing verdict shape. The Webflow page script can then remain unchanged through either framework's Webflow Cloud deployment.

Can I show different messages for additional provider verdicts?

Yes. Extend the route's Verdict reasons when mapping the provider response, then add matching branches before passThrough(data.reason) in the page script. Keep number-specific verdicts separate from configuration and availability failures so you can block actionable input problems while allowing submissions to continue when the validation service itself cannot provide a reliable answer.

Can repeated clicks start more than one phone validation request?

Yes. The script stops each submit event, but it does not turn off the submit control or track a lookup already in progress. Add a per-form in-progress flag before fetch(), ignore additional submits while that flag is set, and clear it after either response path. Keep data-phone-checked reserved for the successful second submit.


Last Updated
October 2, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.