Supabase SMS OTPs with Bird (Formerly MessageBird) in 2025 / by Pranav Khandelwal

I have recently been using Supabase as a backend for a mobile application project. One of the key features of the project is SMS based authentication using one-time-passwords (OTP).

I decided to use Bird (formerly known as MessageBird) as my phone/sms provider. When trying to trigger an OTP code using the Supabase SDK (via Supabase auth), I kept getting the following error:

Request not allowed (incorrect access_key)

This error only occurred when trying to trigger the SMS via Supabase. Using the same access key, I was able to trigger an SMS successfully via curl.

Per Supabase documentation, MessageBird is supported out of the box as an SMS OTP auth provider, however, I learned that this is no longer true. Apparently the Supabase integration to MessageBird is broken due to a change in MessageBird APIs, as indicated by the following comment in a related GitHub issue:

https://github.com/supabase/auth/issues/1554#issuecomment-2292359837

The workaround is to use a new feature called Auth Hooks. These are basically webhooks that Supabase will call to offload the responsibility of actually sending the message. The documentation for Auth Hooks is here: https://supabase.com/docs/guides/auth/auth-hooks?queryGroups=language&language=http

The documentation is decent but leaves a lot to be desired. In any case, I was able to figure out how to implement the Auth Hook specifically with MessageBird.

In general, we need to do 2 things:

  1. Define a Supabase edge function that will be responsible for handling the webhook request and sending the SMS via MessageBird

  2. Configure Supabase to call this edge function when sending an SMS

Step 1: Defining the Edge Function

  1. Create a new edge function using the Supabase CLI: supabase function new send-sms

  2. Adjust the function configuration in config.toml file to set verify_jwt = false

Step 2: Generating the Webhook Secret

It is important that the inbound webhook call is validated to ensure it was actually sent by Supabase and not some unknown sender. In order to validate the function, we first need to generate a secret value:

  1. Create a new environment variable in the .env file called SEND_SMS_HOOK_SECRETS (you can call it whatever you want)

  2. The value of the secret needs to follow the format v1,whsec_<base64-secret> where the <base64-secret> is replaced with a randomly generated value

  3. On mac use the terminal to run the following command openssl rand -base64 32 to generate a value for the <base64-secret>

For example, if running the openssl command returns sqWBTgCp4R1X5O4NH6mBS5mU9U3MwMef2fOfBJ/ezEg= the resulting value in the .env file would be:

SEND_SMS_HOOK_SECRETS=v1,whsec_sqWBTgCp4R1X5O4NH6mBS5mU9U3MwMef2fOfBJ/ezEg=

Step 3: Validating the Webhook

In order to validate the webhook, we can use the standard-webhooks library. In the Supabase edge function, add the following code:

Deno.serve(async (req) => {
  try {
    const payload = await req.text();
    const hookSecrets = Deno.env.get("SEND_SMS_HOOK_SECRETS");
    if (!hookSecrets) {
      return new Response("Unauthorized", { status: 401 });
    }
    const secretKey = hookSecrets.replace("v1,whsec_", "");

    // Extract headers and security specific fields
    const headers = Object.fromEntries(req.headers);
    const wh = new Webhook(secretKey);

    const { user, sms } = wh.verify(
      payload,
      headers,
    ) as SmsWebhookPayload;
});

The return value of wh.verify(…) is a JSON object that has user and sms properties. We only need two values from this payload, the phone number and the otp code.

Refer to the Supabase documentation for an example of the full JSON payload: https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook?queryGroups=language&language=http

Note: The Supabase payload example suggests the the phone number returned in user.phone contains a correctly formatted international phone number including the country code. I noticed that this was not the case. Even though I was sending the phone number properly formatted, the payload received by the webhook endpoint was missing the “+” character which lead to an error. This is easily corrected by adding this to the number prior to make the API request to MessageBird.

Here is a simple TypeScript interface we can use to strongly type the return value of the wh.verify(…) function:

interface SmsWebhookPayload {
  user: {
    phone: string;
  };
  sms: {
    otp: string;
  };
}

Sending the SMS via MessageBird

Now we just need to send the SMS message via MessageBird. In order to do so, we first need to define 3 environment variables that will be necessary to send the request:

  1. SMS_WORKSPACE_ID: The id of the MessageBird workspace

  2. SMS_CHANNEL_ID: The id of the SMS channel configured on MessageBird

  3. SMS_ACCESS_KEY: The MessageBird access key

Add these three variables into the the .env file.

Note: A simple way to find some of these values is to go to the MessageBird dashboard under the Developer section and view the Send your first SMS step in the Get started with SMS API section. There is an example curl command which contains the Workspace Id and Channel Id.

Once the environment variables have been configured, implement the following sendSms function in the edge function:

const sendSms = async (
  phoneNumber: string,
  messageBody: string,
): Promise<MessageBirdResponse> => {
  const workspaceId = Deno.env.get("SMS_WORKSPACE_ID");
  const channelId = Deno.env.get("SMS_CHANNEL_ID");
  const accessKey = Deno.env.get("SMS_ACCESS_KEY");

  if (!workspaceId || !channelId || !accessKey) {
    throw new Error(
      "Missing required environment variables: SMS_WORKSPACE_ID, SMS_CHANNEL_ID, or SMS_ACCESS_KEY",
    );
  }

  const response = await fetch(
    `https://api.bird.com/workspaces/${workspaceId}/channels/${channelId}/messages`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `AccessKey ${accessKey}`,
      },
      body: JSON.stringify({
        receiver: {
          contacts: [{
            identifierValue: `+${phoneNumber}`,
            identifierKey: "phonenumber",
          }],
        },
        body: {
          type: "text",
          text: {
            text: messageBody,
          },
        },
      }),
    },
  );

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(
      `Failed to send SMS: ${response.status} ${response.statusText} - ${errorText}`,
    );
  }

  const data: MessageBirdResponse = await response.json();

  if (data.status !== "accepted") {
    throw new Error(
      `SMS not accepted by MessageBird: ${data.status} - ${data.reason}`,
    );
  }

  return data;
};

Below is a TypeScript interface that can be used for the response from MessageBird:

interface MessageBirdResponse {
  id: string;
  channelId: string;
  sender: {
    connector: {
      id: string;
      identifierValue: string;
    };
  };
  receiver: {
    contacts: Array<{
      id: string;
      identifierKey: string;
      identifierValue: string;
      platformAddressSelector: string;
      platformAddress: string;
      annotations: {
        name: string;
      };
      countryCode: string;
    }>;
  };
  body: {
    type: string;
    text: {
      text: string;
    };
  };
  status: string;
  validity: number;
  reason: string;
  direction: string;
  lastStatusAt: string;
  createdAt: string;
  updatedAt: string;
}

Putting it all together

Finally, we can simply call the sendSms function with the otp code and phone number extracted from the webhook payload:

Deno.serve(async (req) => {
  try {
    const payload = await req.text();
    const hookSecrets = Deno.env.get("SEND_SMS_HOOK_SECRETS");
    if (!hookSecrets) {
      return new Response("Unauthorized", { status: 401 });
    }
    const secretKey = hookSecrets.replace("v1,whsec_", "");

    // Extract headers and security specific fields
    const headers = Object.fromEntries(req.headers);
    const wh = new Webhook(secretKey);

    const { user, sms } = wh.verify(
      payload,
      headers,
    ) as SmsWebhookPayload;

    const messageBody = `Your login code is: ${sms.otp}`;

    // Send the SMS
    await sendSms(user.phone, messageBody);
});

Note: I omitted error handling here to keep the code snippets brief. In a production scenario, remember to properly handle errors.

Deploying the changes:

The last step is to deploy the changes to Supabase. The deployment process is as follows:

  1. Update the edge function secrets in the Supabase dashboard to include the values we created earlier

    1. SEND_SMS_HOOK_SECRETS

    2. SMS_WORKSPACE_ID

    3. SMS_CHANNEL_ID

    4. SMS_ACCESS_KEY

  2. Deploy the send-sms edge function we created. If using Supabase CLI: supabase functions deploy send-sms

  3. Configure the Auth Hook in the Supabase Dashboard:

    1. Go to https://supabase.com/dashboard/project/_/auth/hooks

    2. Add a new hook:

      1. Set the endpoint to be the endpoint of the edge function (can be copied from here: https://supabase.com/dashboard/project/_/functions)

      2. Set the secret equal to the value of the SEND_SMS_HOOK_SECRETS environment variable

And we are Done! Give it a test.

Some closing thoughts:

This isn’t the first time I have encountered poor and outdated documentation with Supabase. For a product targeted at developers, documentation should be a top priority because it directly impacts the developer experience (and therefore the user experience). I hope the Supabase team starts to take documentation quality and thoroughness more seriously and invests more time and resources to it. Right now, too many important details are scattered in various GitHub issues and threads which makes working with the platform painful.