# Supabase push notifications: a push on your phone for every new signup or order

> Get a phone push when a row lands in your Supabase database. A Database Webhook with no code for new signups and orders, then a Postgres trigger with pg_net for status changes, priority levels and a button that opens the table.

*24.10.2026*

<!-- source: https://pocketalert.app/blog/supabase-push-notifications -->

Your app runs on Supabase, and the moments you care about are rows: a new user in `auth.users`, a new order in `orders`, a subscription that flips to `canceled`. You find out about them when you open the Table Editor, which on a good day is in the evening and on a busy day is next week.

Supabase can tell you sooner. Every table can call a URL when a row is inserted, updated or deleted. Point that call at Pocket Alert and each event becomes a push on your phone, with the email or order total in the text.

This guide is about notifying **you**, the founder or admin. If you want to send pushes to your app's own users, that is a job for FCM, APNs or Expo, and a different article.

## What you need

- A Supabase project you can open in the dashboard.
- A Pocket Alert account and the app on your iPhone or Android phone. The Free plan is enough to start.

If you built your app with Lovable and connected your own Supabase project, everything below applies. On Lovable Cloud (the built-in backend) there is no Supabase dashboard, so Level 1 has nowhere to go. Its SQL editor (**Database → Manage → SQL editor**) does support `pg_net` and Vault, so the Level 2 triggers are the route to try there.

## Level 1: a Database Webhook, no code

Supabase Database Webhooks send an HTTP request after a row changes in a table. Pocket Alert webhooks receive JSON and turn it into a push. Connecting the two takes a few minutes and no code.

### Create the Pocket Alert webhook

Open [Webhooks](/webhooks) in the Pocket Alert console and create one:

- **Name:** `Supabase`. The push title is always `Webhook <name>`, so this one arrives as "Webhook Supabase". Use a more specific name, like `Supabase signups`, if you plan to have several.
- **Message:** the template, covered below.

Copy the URL the console shows. It looks like `https://p4a.me/wh/abc123xyz`. The slug at the end is the only secret, so treat the whole URL like a password.

### Create the Supabase webhook

In the Supabase dashboard, open your project and go to **Integrations → Database Webhooks** (older guides call it Database → Webhooks). If the integration is not enabled yet, enable it first; it creates the trigger function the webhooks use. Then create a new webhook:

1. Give it a name, for example `notify_new_order`.
2. Pick the table, for example `orders`.
3. Tick the events. For "new row" alerts, tick only **Insert**.
4. Choose **HTTP Request** as the type, method `POST`, and paste the Pocket Alert URL.
5. Leave the `Content-Type: application/json` header as it is. Pocket Alert only accepts JSON bodies.

Save. From now on, every insert into `orders` sends a request after the row is written. Supabase builds these requests with the `pg_net` extension, which is asynchronous, so a slow network call never holds up the insert itself.

### What Supabase sends

The body has the same shape for every table:

```json
{
  "type": "INSERT",
  "table": "orders",
  "schema": "public",
  "record": { "id": 1042, "email": "ana@example.com", "total": 49, "status": "pending" },
  "old_record": null
}
```

`record` is the new row with all its columns. For `UPDATE`, `old_record` holds the row as it was before; for `DELETE`, `record` is `null` and only `old_record` is filled.

### The message template

Back in the Pocket Alert webhook, write the message with `%path%` placeholders pointing into that JSON:

```text
New %table% row: #%record.id% · %record.email% · $%record.total%
```

The next order arrives as:

> **Webhook Supabase**
> New orders row: #1042 · ana@example.com · $49

Use any column your table has: `%record.plan%`, `%record.country%`, `%record.status%`. A `*` instead of a template sends the whole JSON, which helps while you are figuring out the column names.

The same form has a **default level**. Set `high` on an orders webhook so it gets through Focus on iPhone, or `low` on a busy table you only want in the history. Supabase's payload has no `level` field, so the webhook's default decides.

### Signups from auth.users

Supabase stores users in `auth.users`, and the Database Webhooks table picker can select it: integrations such as Loops document exactly that setup. Choose the users table in the `auth` schema, tick **Insert**, and use:

```text
New signup: %record.email%
```

If the auth tables don't appear in your picker, or you would rather keep the logic in SQL, the trigger in the next section works on `auth.users` too.

## Where Level 1 stops

A Database Webhook has no condition. Tick **Update** on `orders` and you get a push every time any column changes: the status, the shipping address, an `updated_at` touched by a background job. The template can't skip a request; it only formats it. On a busy table that is noise at best, and at worst it eats the 50 messages a day of the Free plan before lunch.

For "tell me when an order becomes paid" or "tell me when a subscription is canceled", you need a filter, and Postgres already has one.

## Level 2: a trigger with pg_net and a WHEN condition

A Database Webhook is a trigger that calls `pg_net` for you. Writing the trigger yourself gets you two things: a `WHEN` clause, so Postgres only fires on the changes you care about, and full control over the request, so you can call the Pocket Alert API directly with your own title, level and buttons.

### Store the API key in Vault

Get an API key from [API Keys](/account/apikeys) and store it in Supabase Vault from the SQL Editor. Run this once, outside your migrations, so the key never ends up in a file:

```sql
select vault.create_secret('YOUR_POCKETALERT_API_KEY', 'pocketalert_key');
```

Make sure `pg_net` is enabled under **Database → Extensions**. Projects with Database Webhooks already have it.

### The function and the trigger

This sends a push only when an order's status changes to `paid`:

```sql
create or replace function public.notify_order_paid()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform net.http_post(
    url := 'https://api.pocketalert.app/v1/messages',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Token', (select decrypted_secret from vault.decrypted_secrets where name = 'pocketalert_key')
    ),
    body := jsonb_build_object(
      'title', 'Order paid: $' || new.total,
      'message', '#' || new.id || ' · ' || coalesce(new.email, 'no email'),
      'level', 'high',
      'actions', jsonb_build_array(jsonb_build_object(
        'type', 'view',
        'label', 'Open orders',
        'value', 'https://supabase.com/dashboard/project/YOUR_PROJECT_REF/editor'
      ))
    )
  );
  return new;
end;
$$;

create trigger on_order_paid
after update of status on public.orders
for each row
when (old.status is distinct from new.status and new.status = 'paid')
execute function public.notify_order_paid();
```

What each part does:

- **`when (...)`** is the filter Level 1 was missing. Postgres doesn't even call the function unless the status actually changed and the new value is `paid`. Edits to other columns cost nothing.
- **`security definer`** lets the function read Vault. The roles that write rows from your app, such as `authenticated`, can't, and without it their updates would fail with a permission error. This is the pattern from the Supabase docs.
- **`net.http_post`** queues the request and returns at once; it is sent after the transaction commits. If the update rolls back, no push goes out.
- **`level`** decides how loudly it lands: `default` for routine events, `high` to break through Focus, `critical` for the ones that should wake you. Critical is a paid feature; on the Free plan it is delivered as `high`.

Copy the function for other events and change the condition: `new.status = 'canceled'` on `subscriptions`, `new.total > 500` on an insert trigger for large orders, `new.role = 'admin'` on `profiles`.

### Signups via a trigger on auth.users

The same pattern on `auth.users`, with the sign-in provider in the text:

```sql
create or replace function public.notify_new_signup()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform net.http_post(
    url := 'https://api.pocketalert.app/v1/messages',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Token', (select decrypted_secret from vault.decrypted_secrets where name = 'pocketalert_key')
    ),
    body := jsonb_build_object(
      'title', 'New signup',
      'message', coalesce(new.email, 'no email') || ' via ' || coalesce(new.raw_app_meta_data ->> 'provider', 'unknown')
    )
  );
  return new;
end;
$$;

create trigger on_auth_user_created_notify
after insert on auth.users
for each row execute function public.notify_new_signup();
```

Supabase warns that a failing trigger on `auth.users` can block signups. The `coalesce` calls keep a missing value from breaking the string, and the request itself is asynchronous, so a slow or failed push doesn't get in the way of the signup. Still, test it once with a throwaway account before you rely on it.

### Or an Edge Function

If you prefer TypeScript to PL/pgSQL, keep the Database Webhook from Level 1, set its type to **Supabase Edge Functions**, and add the secret key header the form offers. The function gets the same `{type, table, record, old_record}` payload and decides for itself. Supabase's pattern for calls with the secret key is the `withSupabase` wrapper with `auth: 'secret'`, plus `verify_jwt = false` for the function in `supabase/config.toml`:

```ts
import { withSupabase } from "npm:@supabase/server@1";

export default {
  fetch: withSupabase({ auth: "secret" }, async (req) => {
    const { type, record, old_record } = await req.json();
    if (type !== "UPDATE" || record.status === old_record.status) {
      return new Response("skipped");
    }

    await fetch("https://api.pocketalert.app/v1/messages", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Token: Deno.env.get("POCKETALERT_API_KEY")!,
      },
      body: JSON.stringify({
        title: `Order #${record.id}: ${record.status}`,
        message: `${record.email} · $${record.total}`,
        level: record.status === "refunded" ? "high" : "default",
      }),
    });
    return new Response("ok");
  }),
};
```

Set the key with `supabase secrets set POCKETALERT_API_KEY=...`. The trade-off: the webhook still fires on every update, and the function filters afterwards, so you pay an invocation per change. The `WHEN` trigger filters before anything runs.

## Honest limits

- **No signature check.** Pocket Alert doesn't verify where a webhook request came from; the URL slug is the secret. If that matters, use the Level 2 trigger, which authenticates with your API key.
- **The key sits briefly in a queue table.** Supabase notes that `pg_net` keeps the decrypted header in `net.http_request_queue` until the request goes out, and any role that can log in to the database can read it there.
- **pg_net is best effort.** Requests live in unlogged tables, so a crash at the wrong moment can lose one. Fine for "tell me about it", not for billing.
- **Volume.** A trigger on a table that gets thousands of writes an hour will send thousands of pushes unless the condition is tight. The Free plan allows 50 messages a day.

## If nothing arrives

- **Check the Pocket Alert webhook history.** Every request to the webhook URL is logged in the console with its headers, body, the rendered message and the status. If the request is there, Supabase did its part and the problem is the template. If it isn't, look at Supabase.
- **Check what pg_net got back.** Responses are kept for six hours:

```sql
select status_code, content, error_msg, created
from net._http_response
order by created desc
limit 10;
```

A `401` means the API key in Vault is wrong; a `400` usually means the body isn't valid JSON or a field is missing.

- **Check the trigger condition.** Run an update by hand that should match, like `update orders set status = 'paid' where id = 1042;`, and look at `net._http_response` again.
- **Check the event.** A webhook that only listens to Insert won't fire on updates, and on `DELETE` the `%record.*%` placeholders are empty because the row is in `old_record`.

## FAQ

### Is this the same as sending push notifications to my app's users?

No. This sends pushes to your own phone through the Pocket Alert app. Pushes to your users' devices go through FCM, APNs or a service like Expo, which your app has to integrate itself.

### Can I get Supabase database webhooks for one column only?

Not with the dashboard webhook; it fires on any change to the row. A trigger with `after update of status` and a `WHEN` condition, as in Level 2, fires only when that column changes the way you describe.

### Does this work on the Supabase free plan?

Database Webhooks, triggers, `pg_net` and Vault are all part of a normal Supabase project, so nothing here needs a paid Supabase plan.

### Can I send different tables to different places?

Yes. Create a Pocket Alert webhook per table, each with its own name, template and level, or put them in separate applications so signups and orders land in separate channels in the app. More ideas on [database alerts](/use-cases/database-alerts) and [signup alerts](/use-cases/signup-alerts).

### Do I have to use Supabase's webhook?

No. Anything that can POST JSON can use a [Pocket Alert webhook](/tools/webhook), and anything that can make an HTTP call can use the API.

[Create a free account](/signup), point one table at it, and the next signup will be on your lock screen.
