supabase

Centralized Exception Handling in Supabase Edge Functions by Pranav Khandelwal

I have been building a full-stack mobile app with Supabase and wanted to centralize my exception handling into a single place.

I had a few goals with this implementation:

  1. I wanted a consistent response shape for all my errors

  2. I wanted to establish a clear pattern for error handling in my edge functions

  3. I wanted to centralize my error handling in one place

The general idea was that all my functions will throw exceptions and a global error handling middleware would capture and handle the exceptions as needed.

My implementation had 3 key components:

  1. An Error Response shape

  2. A CustomError class that extends Error

  3. A Global exception handling “middleware” to wrap all of my edge functions (taking inspiration from .NET)

I decided to model my Error Response like this:

export type ErrorResponse = {
    message: string;
    code: ErrorCode;
    metadata?: Record<string, string>;
};

export type ErrorCode =
    | "RELAY_ERROR"
    | "FETCH_ERROR"
    | "UNKNOWN_ERROR"
    | "UNAUTHORIZED"
    | "VALIDATION_ERROR"

All my API errors will return this in the body. The ErrorCode can be extended to add additional domain specific error codes that my app can use to conditionally execute logic, like rendering various messages or navigating to a specific route.

The metadata object can contain any additional information I wanted to pass back to the app.

Now that I had my shape defined, the next step was to implement my CustomError class:

class CustomError extends Error implements ErrorResponse {
    code: ErrorCode;
    status: number;
    metadata?: Record<string, string>;
    shouldLog?: boolean = false;
    constructor(
        error: string,
        code: ErrorCode,
        status: number,
        metadata?: Record<string, string>,
    ) {
        super(error);
        this.code = code;
        this.status = status;
        this.metadata = metadata;
    }

    toErrorResponse(): Response {
        const errorResponse: ErrorResponse = {
            message: this.message,
            code: this.code,
            metadata: this.metadata,
        };
        return new Response(JSON.stringify(errorResponse), {
            status: this.status,
            headers: {
                "Content-Type": "application/json",
            },
        });
    }

    toLogResponse(): string {
        return JSON.stringify(this.toErrorResponse(), null, 2);
    }
}

My CustomError class extends the base Error type. There are some helper functions to convert my CustomError to a Response object that can be returned from an edge function or a JSON string for logging.

I also added a shouldLog boolean to indicate to my middleware if the error should be logged to the console. Some errors, like validation errors, don’t need to be logged, so this flag helps avoid cluttering the logs with unnecessary lines.

The last step was to implement my middleware. The middleware was implemented as a higher-order-function that can wrap all my edge functions:

export const withGlobalErrorHandler = (
    handler: (req: Request) => Promise<Response>,
) => {
    return async (req: Request) => {
        try {
            return await handler(req);
        } catch (error) {
            logError(error);
            return error instanceof CustomError
                ? error.toErrorResponse()
                : new InternalServerError("Internal Server Error")
                    .toErrorResponse();
        }
    };
};

const logError = (error: unknown) => {
    if (error instanceof CustomError && error.shouldLog) 
        
     else if (error instanceof Error) 
        
     else {
        console.error(`Unknown error: $
    
};

Now, any exceptions thrown by my request handler will be captured here, logged if necessary, and handled appropriately. To use the middleware, I wrapped my edge functions like this:

Deno.serve(withGlobalErrorHandler(async (req) => {
  const  = await req.json() as { postId: string };
  
  const post = await getPost(postId);

  if (!post) {
    throw new CustomError("Post not found", "NOT_FOUND", 404);
  }

  return new Response(JSON.stringify(post), {
    headers: { "Content-Type": "application/json" },
  });
}));

Any exception thrown in the request handler will now be captured and handled by the middleware.

Closing Thoughts

There is a lot of room for improvement here. For instance, using exceptions for flow control is considered an anti-pattern. A better approach would be to use a Result type.

One obvious benefit of moving away from exceptions to something like a Result type is that the middleware can handle returning the API response for all possible outcomes: errors, unhandled exceptions, and successful results. Adding common headers, serializing the response, and other logic could then be centralized instead of duplicated across edge functions.

That being said, I like to be pragmatic instead of dogmatic. Since this is a hobby project, I just need something that is good enough to work for my use case. Software is built iteratively, so I can continue to improve the implementation overtime.

Connecting to Supabase running locally from Android Emulators by Pranav Khandelwal

While working on a mobile app using React Native and Expo, I ran into an issue where I was unable to connect to a local instance of Supabase from my Android Emulator.

My local Supabase instance was running at: http://127.0.0.1:54321.

When initializing the Supabase JavaScript client, I was simply passing in this URL and anon key:

const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey)

While this worked fine on iOS Simulator, on Android Emulators, I would keep getting an error:

TypeError: Network request failed

Per the official Android documentation, the reason why is because Android Emulators sit behind their own virtual router, so the virtual devices can't see the host computer. They only see a router connection. That router gives the emulator an IP in the 10.0.2.x range, keeping all traffic inside this private network.

The fix is to use a special alias IP that points to the host computer's own local address (127.0.0.1). Per the Android docs, this alias IP is:

10.0.2.2

Changing the URL to http://10.0.2.2:54321 fixed the issue on Android, but caused iOS to break with the same error: TypeError: Network request failed.

To fix the issue for both platforms, I used the Platform helper from react-native to conditionally select the correct URL:

export const supabaseUrl = Platform.select({
  ios: http://127.0.0.1:54321,
  android: http://10.0.2.2:54321,
});

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.