Skip to content

Check phone numbers in an Auth0 custom phone provider Action with Spaw

updated · September 27, 2026

A custom-phone-provider Action that asks Spaw about the number and the visitor's IP, drops codes your SMS policy would not send and sends the rest.

Mechanism
custom-phone-provider Action calling the API
Works today
Yes, with Custom selected under Branding, Phone Provider
Refusing
api.notification.drop(); no code is sent
Visitor's IP
Yes, event.request.ip when Auth0 provides it, sent as client_ip
Cost
1 credit per valid number; resends within 7 days free

Auth0 sends one-time codes through the tenant's phone provider, and a Custom provider is an Action on the custom-phone-provider trigger that delivers each message itself. That Action is where Spaw's check goes: it asks about the number and the visitor's address, drops the message with api.notification.drop() when your account's SMS policy answers ok_to_send: false, and otherwise sends it. Auth0 sends nothing on its own once the Custom provider is selected, so the recipe below includes the SMS call.

What the Action receives

From Auth0's documentation of the custom phone provider and its event and API objects, read on 27 September 2026:

Item What Auth0 provides
Where Branding, Phone Provider, Custom; one provider per tenant for MFA and passwordless (Auth0's Unified Phone Experience, where new tenants are enrolled automatically)
The number event.notification.recipient, in E.164 form
The visitor's address event.request.ip, "the originating IP address of the request"; Auth0's own passwordless example allows for event.request being undefined, so the sample does too
The flow event.notification.message_type: otp_verify, otp_enroll, blocked_account, change_password or password_breach
The message event.notification.as_text, ready to send; code on the OTP types
Refusing api.notification.drop(reason): logged as failed and never retried; retry(reason) asks for up to five more attempts
Time limit Each Action execution must complete in 20 seconds

The older send-phone-message trigger runs only for MFA by SMS or voice, and Auth0 says it "should not be used for configuring a custom phone provider". A tenant still on it can use the same check: its event carries the number as event.message_options.recipient, the address as event.request.ip, and its API object has no drop method, so a refusal there means returning without sending.

Before you start

  1. Create a secret API key on the API keys page of your Spaw dashboard, scoped to phone, with a daily credit cap above a normal day's sign-ins, and a feedback key on the same page for the delivery-receipt URL.
  2. Switch the SMS policy's opt-out rule off, so a person who once replied STOP to a marketing message can still receive a code:
curl -X PUT https://spaw.co/api/v1/phone/policy \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"block_opted_out": false}'

An account has one policy, applied to every lookup it makes. With the rule off, an opted-out number is decided like any other and the answer still says meta.opted_out: true, so a marketing flow on the same account reads that field itself.

  1. In the Auth0 Dashboard open Branding, Phone Provider, select Custom and the Text delivery method, paste the Action below into Provider Configuration, and add the secrets SPAW_KEY, SPAW_FEEDBACK_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_PHONE_NUMBER. Save deploys it, and Send Test Message sends one through it.

The Action

The sample sends through Twilio's Messages API; any provider's HTTP call fits in the same place.

/**
 * Handler to be executed while sending a phone notification.
 *
 * @param {Event} event - Details about the user and the context of the message.
 * @param {CustomPhoneProviderAPI} api - Methods to drop or retry the notification.
 */
exports.onExecuteCustomPhoneProvider = async (event, api) => {
  const { recipient, as_text, message_type } = event.notification;
  // Add "otp_verify" if passwordless SMS sign-up creates accounts on your tenant.
  const refuseOn = new Set(["otp_enroll"]);

  if (message_type.startsWith("otp")) {
    let answer = null;

    try {
      const response = await fetch("https://spaw.co/api/v1/phone", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${event.secrets.SPAW_KEY}`,
          "Content-Type": "application/json",
          Accept: "application/json",
        },
        body: JSON.stringify(event.request?.ip ? { phone: recipient, client_ip: event.request.ip } : { phone: recipient }),
        signal: AbortSignal.timeout(2500),
      });

      answer = response.ok ? (await response.json()).data : null;
    } catch {
      // Fail open: the code goes out unchecked.
    }

    if (answer && !answer.ok_to_send && answer.blocked_by !== "opted_out") {
      console.log(JSON.stringify({ message_type, spaw_blocked_by: answer.blocked_by }));

      if (refuseOn.has(message_type)) {
        api.notification.drop(`Spaw: ${answer.blocked_by}`);
        return;
      }
    }
  }

  const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, SPAW_FEEDBACK_KEY } = event.secrets;
  const sent = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`, {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString("base64"),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      To: recipient,
      From: TWILIO_PHONE_NUMBER,
      Body: as_text,
      StatusCallback: `https://spaw.co/api/v1/phone/feedback/twilio/${SPAW_FEEDBACK_KEY}`,
    }),
  });

  if (!sent.ok) {
    api.notification.retry(`The SMS provider answered ${sent.status}`);
  }
};

Spaw receives the number and the visitor's address and nothing else: not the code, the message text or anything from event.user. client_ip is counted, never looked up and never billed; the privacy policy describes how the count is kept. Only the OTP types are checked: the account notices (blocked_account, change_password, password_breach) go to people who already have an account and are always sent. The sample handles text delivery; a tenant set to voice replaces the Messages call with its provider's call API. It follows Auth0's documented event and API objects; run it in your own tenant before you rely on it.

Which codes it refuses, and which it only logs

otp_enroll is a number being attached to an account for multi-factor authentication, and the sample refuses it. otp_verify is logged and sent by default, because it covers both an enrolled user's second factor and a passwordless sign-in, and refusing an enrolled user locks a customer out. On a tenant where passwordless SMS creates accounts, add otp_verify to refuseOn: Auth0 creates a new passwordless user's profile only after the first code is entered, so for a sign-up otp_verify is the first code the number ever gets. The cost is that a returning user whose number is later refused needs support to get back in.

A dropped message is logged by Auth0 as failed and not retried, and the trigger's API object has no method that shows an error on the sign-in screen: the person waits for a code that does not come. Say on that screen what to do when no code arrives.

A refusal names the rule in blocked_by; the gate-SMS guide explains each rule and the order they apply in. With the visitor's address, five distinct numbers tried from one address within 60 minutes add numbers_per_client (+30). An address Spaw's IP data places on a mobile carrier or a corporate proxy, where many people share one address, still answers the count but never adds the signal. Five distinct numbers in one 1,000-number range within 60 minutes add range_burst (+40). Either alone stays under the default ceiling of 60; the two together score 70 and answer blocked_by: "risk_score".

Why it fails open

A timeout, a network error, 402 INSUFFICIENT_CREDITS on an empty balance, 429 when the key's daily cap is spent or your phone requests exceed 50 a second, or a 5xx leaves answer null, and the message is sent as if the check did not exist. Failing closed would turn a Spaw outage into an outage of your sign-in. The 2.5-second limit keeps the Spaw call well inside Auth0's 20 seconds with room for the provider's call.

If the code goes through Twilio Verify

Auth0's own guide to a custom phone provider sends the code through Twilio Verify from this same trigger, calling verifications.create with the code Auth0 generated. The Spaw check belongs in the same place in that Action, before the verifications.create call, exactly where it sits before the Messages call above.

The same two calls wrap any backend that uses Verify directly, without Auth0: check the number before verifications.create, and report verified when verificationChecks.create answers status: "approved". Verify runs its own SMS fraud controls: Twilio's documentation (updated 9 March 2026) says Fraud Guard "is on by default for all Verify customers" and that Geo Permissions adjust it country by country. They act on Twilio's side of the send and know nothing of your SMS policy, your own outcome reports or your own traffic, so the two checks complement each other rather than overlap.

Delivery receipts and verified codes

The StatusCallback in the sample points Twilio's delivery receipts at your SMS delivery-receipt URL. Spaw reads delivered, undelivered and failed, classifies the error code, and records an opt-out code such as Twilio 21610 as opted_out; receipts are free and feed only your own later lookups.

A passwordless SMS login where the person has just entered a code can be reported as verified from a post-login Action. Auth0's login-trigger page says the trigger also runs "when a Refresh Token is exchanged", and a login that reuses an existing session runs it too, with no code entered in either case. event.authentication.methods keeps every method completed during the session, each with a timestamp, and Auth0 names an SMS one-time code sms. The sample therefore reports only when that sms entry is less than five minutes old:

exports.onExecutePostLogin = async (event, api) => {
  // Refresh-token exchanges and logins that reuse a session run this trigger too.
  const sms = (event.authentication?.methods ?? []).find((method) => method.name === "sms");

  if (
    event.connection.strategy !== "sms" ||
    !event.user.phone_number ||
    event.transaction?.protocol === "oauth2-refresh-token" ||
    !sms ||
    Date.now() - new Date(sms.timestamp).getTime() > 5 * 60 * 1000
  ) {
    return;
  }

  try {
    await fetch("https://spaw.co/api/v1/phone/feedback", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${event.secrets.SPAW_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ items: [{ phone: event.user.phone_number, outcome: "verified" }] }),
      signal: AbortSignal.timeout(1500),
    });
  } catch {
    // A missed report only means one less outcome on your account.
  }
};

The post-login event object does not document the number of an MFA factor, so a multi-factor code is reported from wherever your app keeps that number, or not at all. The feedback summary then reads verified_share, the share of the numbers you reported delivered or verified that were verified, overall and for each of the ten blocks with the most failed deliveries; pumped traffic is delivered and never verified.

Try the branches without Auth0

The documented test numbers answer a fixed result each, cost nothing and are never counted in your traffic. Under the default policy +1 202 555 0103 answers ok_to_send: false with blocked_by: "disposable", +1 202 555 0105 answers blocked_by: "fictional", and +1 202 555 0102 (the test number that answers the virtual signal, score 40) and +1 202 555 0100 answer ok_to_send: true. Post them to https://spaw.co/api/v1/phone with curl to see each branch before the Action is live.

What to leave out

Leave hlr out of the request. The live carrier-network check (hlr: true) is not enabled on the service: hlr_checked answers false and nothing extra is charged. Leave the policy's require_reachable rule off for the same reason: while the check is not enabled, no lookup answers ok_to_send: true under it: every number that passes the other rules answers blocked_by: "unconfirmed".

ok_to_send is a fraud and deliverability decision. It is not consent and not a do-not-call scrub, and whether the person agreed to hear from you stays yours to record.

Cost

A lookup costs 1 credit when the number is valid. Invalid numbers, the test numbers and a repeat of the same number within seven days are free, so a resent code costs nothing. Delivery receipts and outcome reports are free. The SMS itself is billed by your provider. Spaw is not affiliated with Okta, Auth0 or Twilio.

Related

markdown version: /integrations/auth0.md

Verify addresses the same way

The Spaw API runs every check described here on each lookup, with an SMTP handshake that never sends mail. 10 free lookups a month, no card required.

Get your API key

More integrations