Supabase Database Change Notifications
Supabase can fire a webhook on every insert. Point it at your phone and know the second a signup, order, or error row lands in the table.

Your Supabase table is the source of truth. A row appears when someone signs up, an order lands, or a background job records a failure. The database knows immediately. You find out when you next run a query.
Supabase has two ways to close that gap, and which one you want depends entirely on whether you care what the notification says.
Option 1: A database webhook, no code
Database webhooks fire an HTTP request when a row changes. Create an application in TheNotificationApp, call it Supabase, copy the app_key, and build the URL:
https://thenotification.app/api/webhook/static/?app_key=YOUR_API_KEY&title=New%20signupIn the Supabase dashboard go to Database, then Webhooks, then Create a new hook:
- Table: the one you care about
- Events: Insert only, unless you genuinely want updates too
- Type: HTTP Request, POST
- URL: the one above
- HTTP Headers:
Content-Type: application/json
Save, insert a test row, and your phone buzzes with the whole record formatted into readable text.
Two minutes, no code, and it is genuinely enough when the fact that a row appeared is the information you wanted. The weakness is that you get every column, including the ones you do not care about, and you cannot filter which rows fire it from this screen alone.
Option 2: An edge function, when the message matters
When you want "New signup: alex@acme.com on the Pro plan" rather than a dump of the row, put a function in between.
// supabase/functions/notify-signup/index.ts
Deno.serve(async (req) => {
const { record, type, table } = await req.json();
// Only inserts, and only rows that matter.
if (type !== "INSERT") {
return new Response("ignored", { status: 200 });
}
if (record.plan === "free") {
return new Response("skipped", { status: 200 });
}
const response = await fetch("https://thenotification.app/api/sendNotification", {
method: "POST",
headers: {
app_key: Deno.env.get("TNA_APP_KEY")!,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `New ${record.plan} signup`,
body: `${record.email} joined from ${record.country ?? "unknown"}`,
link: `https://supabase.com/dashboard/project/YOUR_REF/editor`,
}),
});
if (!response.ok) {
console.error("notify failed", response.status, await response.text());
}
return new Response("ok", { status: 200 });
});Set the secret and deploy:
supabase secrets set TNA_APP_KEY=your_app_key_here
supabase functions deploy notify-signup --no-verify-jwtThe --no-verify-jwt flag is the one that trips people up. A database webhook is not an authenticated user, so without it the function rejects the request with a 401 and you get silence with no obvious cause.
Then point the database webhook at the function URL instead of the notification URL.
Filter in the database, not in the function
The guard above works, but it means the function runs for every insert and then decides to do nothing. On a busy table that is invocations you pay for and latency you do not need.
If your filter is simple, put it in a trigger instead. Supabase webhooks are Postgres triggers underneath, so you can write your own:
create or replace function notify_paid_signup()
returns trigger
language plpgsql
security definer
as $fn$
begin
perform net.http_post(
url := 'https://thenotification.app/api/sendNotification',
headers := jsonb_build_object(
'app_key', current_setting('app.tna_key', true),
'Content-Type', 'application/json'
),
body := jsonb_build_object(
'title', 'New ' || new.plan || ' signup',
'body', new.email
)
);
return new;
end;
$fn$;
create trigger on_paid_signup
after insert on public.profiles
for each row
when (new.plan <> 'free') -- the filter lives here
execute function notify_paid_signup();The when clause means the trigger does not even fire for free signups, which is cheaper and simpler than a function that returns early. This uses the pg_net extension, which Supabase enables for you.
One caution: pg_net calls are asynchronous and fire-and-forget. If the notification fails you will not hear about it from the transaction, and you should not want to, because a failed notification must never roll back a signup.
Debugging a webhook that never fires
Supabase logs every webhook attempt, which makes this much less painful than it usually is. The failures cluster into three causes.
Nothing in the logs at all. The trigger is not firing. Check the table and the event type, and check that the row you inserted actually matches any when clause. Query the underlying trigger directly to confirm it exists:
select tgname, tgenabled
from pg_trigger
where tgrelid = 'public.profiles'::regclass
and not tgisinternal;A 401 in the logs. The classic edge function case: deployed without --no-verify-jwt, so the function rejects an unauthenticated caller. Redeploy with the flag.
A 200, but no notification. The request reached the API and the API declined it. Look at the response body in the log. A 400 means title or body was missing, usually because a column was null and the template produced nothing. A 401 means the app_key header is wrong, which for the trigger version usually means current_setting returned empty because the setting was never set.
For pg_net calls specifically, responses land in a table you can query, which is the only way to see them:
select id, status_code, content
from net._http_response
order by created desc
limit 10;Do not notify from the client
Supabase makes it easy to subscribe to realtime changes in the browser and fire a notification from there. Do not: it puts your app key in client-side JavaScript, where anyone can read it and use it.
Everything in this post runs server-side, in a trigger or an edge function, which is why the key lives in a database setting or a function secret rather than in your frontend environment.
What is worth a notification
- A paid signup or upgrade. The one people actually want.
- A row in an errors or failed_jobs table. These accumulate silently by design.
- A support or contact message. Reply speed is the product for small teams.
- A row that should never exist. A negative balance, an orphaned record, a duplicate. Notify on the invariant breaking.
Not worth it: every insert on a busy table, every update, anything that happens dozens of times an hour.
The honest part
The free tier is 100 notifications for the lifetime of the account, not per month. Filtered to paid signups on a young product, that is months of use and a nice ceiling to hit. Pointed at every insert on an events table, it is about ninety seconds.
This is exactly why the filter belongs in the trigger. Pro is $2.99 a month for 1,000, and this reaches iPhone only because it rides Apple's push service.
Where this fits
If you are building on Supabase through Lovable, the same idea from that angle is in Lovable push notifications on iPhone. The webhook parameters are in the static webhook docs.
Grab a free key at thenotification.app and let the database tell you when something happened.
New to this? Start with what a push notification API actually is.



