# How to Send a Supabase Database Event to Your Team's Phones

> Use a dedicated event table, an authenticated Supabase Edge Function, and one privacy-minimal PingRoom webhook call for a selected database event.

- Published: 2026-10-30
- Author: PingRoom
- Category: Developers
- Topics: Supabase database webhook push notification, Supabase new row phone alert, Supabase Edge Function PingRoom
- Canonical: https://pingroom.io/blog/supabase-database-webhook-push-notifications
- Cover image: https://pingroom.io/blog/supabase-database-webhook-push-notifications-cover.webp
- Cover description: Two PingRoom avatars place one sealed clay event token into a wooden tray while Pingo watches from the workbench.

**When one deliberate Supabase event should reach a small shared group, insert a UUID into a dedicated event table, let a Database Webhook call an authenticated Edge Function, and have the function send fixed copy to a PingRoom Room. Keep the actual record and workflow state in your application.**

This pattern is for a bounded attention signal such as **A review queue has one new item**. It does not mirror arbitrary rows to a lock screen. The function below accepts only an `INSERT` on `public.attention_events`, reads only the event UUID, and forwards no database field values.

This is a webhook recipe, not a native Supabase integration. An accepted PingRoom request does not prove that every phone displayed a notification or that anyone completed the work.

## Pick the right Supabase path first

Supabase already has better owners for two nearby jobs. Use Realtime when your signed-in application needs live database changes. Use Supabase's [push-notification example](https://supabase.com/docs/guides/functions/examples/push-notifications) when your application owns device tokens and sends directly to its own users.

Add a shared PingRoom path only when one intentional event should reach a separate Room.

| Need                                                      | Better starting point                                     |
| --------------------------------------------------------- | --------------------------------------------------------- |
| Keep an open application view synchronized                | Supabase Realtime                                         |
| Send app-owned notifications to app-owned device tokens   | Your app's push pipeline                                  |
| Give a small shared group one fixed phone alert           | Database Webhook → authenticated Edge Function → PingRoom |
| Preserve record details, permissions, or completion state | Your application and Supabase                             |

The last row is the boundary. PingRoom carries the knock on the door; it is not the database, queue, or audit log.

## Create a table that represents attention, not data

Do not point the webhook at a customer, order, support, or profile table. Create a narrow event table with no descriptive payload:

```sql
create table public.attention_events (
  event_id uuid primary key default gen_random_uuid(),
  created_at timestamptz not null default now()
);

alter table public.attention_events enable row level security;
```

With no client policy, browser and mobile clients cannot insert directly through the Data API. Have a trusted backend, database function, or other explicitly authorized server path insert one row only after the source workflow reaches the state you chose.

The UUID is the event's stable identity. The timestamp is useful for database operations, but the relay does not forward it. Your source record keeps the actual details.

## Create the Room and store two credentials

Create or choose a private Room for the people who should receive the signal. The Room owner needs **PingRoom Pro** to create an incoming webhook.

Open **Settings → Connections → From another app**, add an incoming webhook, and leave **Quick Ping** on **Custom message**. Copy the full URL. The [incoming-webhook walkthrough](/blog/webhook-to-push-notification) explains the Room setup; the [webhook reference](/documents/webhooks) carries the current request limits, cooldown, idempotency behavior, and response contract.

Store these as Supabase Edge Function secrets:

```text
PINGROOM_WEBHOOK_URL=https://api.pingroom.io/...
PINGROOM_EVENT_LABEL=Review queue
```

Supabase's [secret-management guide](https://supabase.com/docs/guides/functions/secrets) documents the Dashboard and CLI paths. Do not commit the values, expose them to browser code, place them in migrations, or log them. The PingRoom URL is a credential.

## Authenticate the Edge Function caller

Create a function named `pingroom-attention`, then disable the platform JWT check for that function:

```toml
# supabase/config.toml
[functions.pingroom-attention]
verify_jwt = false
```

That setting does not make the handler public by itself. The function uses Supabase's current `withSupabase({ auth: "secret" })` wrapper, which accepts the project's `default` secret key on the `apikey` header. Supabase documents this combination for service-to-service callers such as `pg_net` and Database Webhooks.

Put this in `supabase/functions/pingroom-attention/index.ts`:

```js
// supabase-attention-handler-start
import { withSupabase } from "npm:@supabase/server@1";

const MAX_BODY_BYTES = 32 * 1024;
const UUID =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

function reply(status, body = "") {
  return new Response(status === 204 ? null : body, {
    status,
    headers: body ? { "Content-Type": "text/plain; charset=utf-8" } : {},
  });
}

function singleLine(value, max) {
  return (
    typeof value === "string" &&
    value.length > 0 &&
    value.length <= max &&
    !/[\r\n]/.test(value)
  );
}

export async function handleSupabaseAttention(request, env = Deno.env) {
  if (request.method !== "POST") return reply(405, "Method not allowed");

  const webhookUrl = env.get("PINGROOM_WEBHOOK_URL");
  const label = env.get("PINGROOM_EVENT_LABEL");
  if (!webhookUrl) return reply(500, "Server configuration error");

  let destination;
  try {
    destination = new URL(webhookUrl);
  } catch {
    return reply(500, "Server configuration error");
  }

  if (destination.protocol !== "https:" || !singleLine(label, 80)) {
    return reply(500, "Server configuration error");
  }

  const declaredLength = Number(request.headers.get("content-length") ?? "0");
  if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
    return reply(413, "Payload too large");
  }

  const rawBody = await request.text();
  if (new TextEncoder().encode(rawBody).byteLength > MAX_BODY_BYTES) {
    return reply(413, "Payload too large");
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return reply(400, "Invalid JSON");
  }

  const selected =
    event?.type === "INSERT" &&
    event?.schema === "public" &&
    event?.table === "attention_events";

  if (!selected) return reply(204);

  const eventId = event?.record?.event_id;
  if (!UUID.test(eventId ?? "")) return reply(400, "Invalid event identity");

  const replayKey = `supabase-attention:${eventId}`;
  const body = JSON.stringify({
    action: null,
    title: "Supabase event ready",
    message: `${label} has one new item. Open the source app for details.`,
    correlation_id: replayKey,
  });

  let response;
  try {
    response = await fetch(destination, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "Idempotency-Key": replayKey,
      },
      body,
      redirect: "error",
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return reply(502, "PingRoom request failed");
  }

  if (!response.ok) return reply(502, "PingRoom rejected the alert");

  try {
    const accepted = await response.json();
    if (accepted.success !== true) {
      return reply(502, "PingRoom rejected the alert");
    }
  } catch {
    return reply(502, "PingRoom returned an invalid response");
  }

  return reply(200, "Accepted");
}

export default {
  fetch: withSupabase({ auth: "secret" }, (request) =>
    handleSupabaseAttention(request),
  ),
};
// supabase-attention-handler-end
```

The wrapper performs the caller authentication. The core handler then enforces the method, payload size, event type, schema, table, UUID, fixed copy, and HTTPS destination. It never queries the changed row and never forwards `old_record` or any other payload field.

## Point one Database Webhook at the function

Deploy the function and open **Database → Webhooks** in the Supabase Dashboard. Create a webhook with these choices:

- Table: `public.attention_events`
- Event: `INSERT` only
- Type: Supabase Edge Function
- Function: `pingroom-attention`
- Method: `POST`
- Authentication: the project's secret key, as described in Supabase's [Database Webhooks guide](https://supabase.com/docs/guides/database/webhooks)

Database Webhooks run after a row changes and use the asynchronous `pg_net` extension. The insert remains the durable source event. If the network request fails, inspect the webhook history and function logs before retrying; do not create a new event UUID merely to make another attempt.

![Two PingRoom avatars inspect one accepted clay event token beside a closed duplicate channel while Pingo watches by a paper ledger.](/blog/supabase-database-webhook-push-notifications-1.webp)

[Pingo the Pigeon](/Pingo), PingRoom's fictional Chief Marketing Officer, is guarding the empty duplicate channel with his usual administrative enthusiasm. He cannot read Supabase, enter a private Room, authenticate the function, or send the Ping. The human team chooses the event, recipients, copy, credentials, and response to a failure.

_These AI-generated editorial illustrations feature approved PingRoom avatars and Pingo. They are not Supabase or PingRoom screenshots and do not depict customers._

## Make retries repeat the same event

The function maps `record.event_id` to both `Idempotency-Key` and `correlation_id`. If Supabase retries the same payload, PingRoom can return the original accepted result instead of creating another Ping.

Keep the request body identical for that UUID. Changing `PINGROOM_EVENT_LABEL` changes the body under the same key, so a retry can conflict. Treat a copy change as a configuration change for future events, not a way to rewrite one already in flight.

PingRoom incoming webhooks have a default five-second Room cooldown. Two genuinely different events can therefore compete even though their idempotency keys differ. If events can burst, put a queue or deliberate aggregation step before the Room rather than creating more webhook URLs or dropping replay safety.

## Test the boundaries before using a real workflow

Use a test project and a small test Room with non-sensitive copy.

1. Call the function without the Supabase project secret. The wrapper should reject the request before the core handler runs.
2. Send an `UPDATE`, another schema, or another table. The handler should return `204` and send no Ping.
3. Insert one row into `attention_events`. One fixed **Supabase event ready** Ping should enter the Room.
4. Retry the same generated payload. It should not create a second Ping.
5. Change the event label while retrying the same UUID. PingRoom can reject the changed body under the existing key.
6. Send a malformed, oversized, or non-UUID payload. The handler should reject it without calling PingRoom.
7. Temporarily use a test PingRoom URL that rejects the request. The function should return `502`; the inserted event remains in Supabase for inspection.

For this article, we extracted the exact core handler and ran it against Supabase-shaped fixtures plus a stateful local PingRoom mock. The checks covered acceptance, exact replay, changed-body conflict, method/schema/table/type filters, invalid identity, malformed and oversized bodies, missing configuration, downstream rejection, fixed copy, privacy exclusions, and required headers. They did not use a live Supabase project, Edge Function, database, PingRoom Room, phone, or notification receipt.

An HTTP `200` with `Accepted` means the function received a PingRoom success response. It does not prove that every phone displayed the Ping. Connectivity, notification permissions, Focus settings, Room mutes, and operating-system policy still affect presentation.

If a single database event genuinely deserves shared attention, [create the destination Room and incoming webhook](/app/rooms), deploy the authenticated function, and test one non-sensitive insert end to end.

_Supabase is a trademark of Supabase, Inc. PingRoom is not affiliated with or endorsed by Supabase._
