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

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.

Updated: 2026-09-27

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](/docs/phone-intelligence#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:

```bash
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.

3. 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.

```javascript
/**
 * 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](/privacy) 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](/guides/gate-sms-verification-codes-with-the-sms-policy) 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`](/docs/signals/phone/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](/docs/api/sms-status-webhook). 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:

```javascript
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](/docs/api/phone-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.

Reference: https://spaw.co/integrations/auth0
