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.

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 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:
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 explains the Room setup; the webhook reference carries the current request limits, cooldown, idempotency behavior, and response contract.
Store these as Supabase Edge Function secrets:
PINGROOM_WEBHOOK_URL=https://api.pingroom.io/... PINGROOM_EVENT_LABEL=Review queue
Supabase's secret-management guide 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:
# 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:
// 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:
INSERTonly - Type: Supabase Edge Function
- Function:
pingroom-attention - Method:
POST - Authentication: the project's secret key, as described in Supabase's Database Webhooks guide
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.

Pingo the Pigeon, 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.
- Call the function without the Supabase project secret. The wrapper should reject the request before the core handler runs.
- Send an
UPDATE, another schema, or another table. The handler should return204and send no Ping. - Insert one row into
attention_events. One fixed Supabase event ready Ping should enter the Room. - Retry the same generated payload. It should not create a second Ping.
- Change the event label while retrying the same UUID. PingRoom can reject the changed body under the existing key.
- Send a malformed, oversized, or non-UUID payload. The handler should reject it without calling PingRoom.
- 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, 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.
PingRoom
Short-form communication.


