Skip to main content
20% off 3 months · code LAUNCH20 · until 31 October

Get notified of every Supabase signup, and where it came from

23 September 2026 · by Nordva, who make Nordva Launch

On this pageStep 1: a profiles table filled by a trigger
  1. Step 1: a profiles table filled by a trigger
  2. Step 2: Slack straight from Postgres
  3. Step 3: an Edge Function, when you want email too
  4. Step 4: record where the signup came from
  5. The shorter way with Nordva Launch
  6. Back to the top

How do I get a notification when someone signs up in Supabase, and see where they came from?

Copy each new auth.users row into a public profiles table with a trigger, then fire a second trigger on that table: pg_net can post straight to a Slack webhook, or call an Edge Function that posts to Slack and sends you an email. For the source, save the first UTM tags and referrer in the browser and pass them as user metadata at signUp.

A new app on Supabase gets its first strangers from a post somewhere, and you want to know two things when it happens: that someone signed up, and which post sent them. Supabase gives you everything for the first part inside the database. The second part has to be caught in the browser, because by the time the user row exists, the URL they arrived on is gone.

This guide builds both, in the order you would do it: a profiles table, a notification (Slack straight from Postgres, or an Edge Function when you also want email), then the source.

Step 1: a profiles table filled by a trigger

The auth schema is not exposed in Supabase’s generated API, and Supabase’s user management guide recommends a table of your own in public that references auth.users by its primary key, filled by a trigger. The same guide warns that “if the trigger fails, it could block signups, so test your code thoroughly.” This version adds two columns: the email, and a first_touch object that step 4 fills in.

create table public.profiles (
  id uuid not null references auth.users on delete cascade,
  email text,
  first_touch jsonb,
  created_at timestamptz not null default now(),
  primary key (id)
);

alter table public.profiles enable row level security;
grant select on public.profiles to authenticated;
-- authenticated has table-wide UPDATE by default; narrow it to the one column.
revoke update on public.profiles from authenticated;
grant update (first_touch) on public.profiles to authenticated;

create policy "Read own profile" on public.profiles
  for select to authenticated using ((select auth.uid()) = id);
create policy "Fill in own first touch" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id and first_touch is null)
  with check ((select auth.uid()) = id);

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, email, first_touch)
  -- nullif: a first_touch sent as JSON null is stored as SQL null, so step 2 says "unknown".
  values (new.id, new.email, nullif(new.raw_user_meta_data -> 'first_touch', 'null'::jsonb));
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

If you already have a handle_new_user function from Supabase’s starter code, add the two columns to its insert rather than creating a second one. Keep the revoke: Supabase’s column-level security guide notes that by default “the authenticated role can update all the columns in the table”, so a column grant alone restricts nothing.

Why not put the notification on auth.users directly? You can, but your code then depends on a schema Supabase manages, and the same guide says objects there other than the primary key “may change at any time”. The profiles row is yours.

Step 2: Slack straight from Postgres

The shortest path has no function at all. Database Webhooks are “a convenience wrapper around triggers using the pg_net extension”, and you can call pg_net yourself from a trigger. Two properties matter here: pg_net is asynchronous, and “HTTP requests are not started until the transaction is committed”, so a slow or broken Slack cannot hold up the signup.

Create a Slack incoming webhook first (an app with Incoming Webhooks turned on; Slack’s guide has the steps). Slack says the URL “contains a secret”, so keep it out of your migrations and put it in Supabase Vault, which stores it encrypted and decrypts it through the vault.decrypted_secrets view. Run this once in the SQL editor, not in a migration file:

create extension if not exists pg_net with schema extensions;
select vault.create_secret('https://hooks.slack.com/services/…', 'slack_signups_webhook');

Then the trigger:

create function public.notify_slack_new_profile()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
declare
  hook text;
  source text;
begin
  select decrypted_secret into hook
  from vault.decrypted_secrets where name = 'slack_signups_webhook';

  source := coalesce(
    new.first_touch ->> 'utm_source',
    new.first_touch ->> 'referrer',
    case when new.first_touch is null then 'unknown' else 'direct' end
  );

  if hook is not null then
    perform net.http_post(
      url := hook,
      body := jsonb_build_object(
        'text', format('New signup: %s from %s', coalesce(new.email, new.id::text), source)
      )
    );
  end if;
  return new;
exception when others then
  raise warning 'signup notification skipped: %', sqlerrm;
  return new;
end;
$$;

create trigger on_profile_created_notify_slack
  after insert on public.profiles
  for each row execute function public.notify_slack_new_profile();

The exception block is the part people leave out. Without it, a missing secret or a typo in the function raises an error inside the signup’s transaction, and the signup fails with it. With it, the worst case is one missed message and a warning in the Postgres logs.

To see what Slack answered, query net._http_response. The pg_net docs say responses are kept there for 6 hours by default:

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

Step 3: an Edge Function, when you want email too

Slack is one POST with a text field. Email needs an API key and a sender, and more formatting than SQL is pleasant for, so move the work into an Edge Function. This one posts to Slack and sends you an email through Resend’s send endpoint; drop either half.

// supabase/functions/notify-signup/index.ts
import { withSupabase } from "npm:@supabase/server";

type FirstTouch = { utm_source?: string; utm_campaign?: string; referrer?: string; landing?: string };
type Profile = { id: string; email: string | null; first_touch: FirstTouch | null };
type InsertPayload = { type: "INSERT"; table: string; schema: string; record: Profile; old_record: null };

const SLACK_URL = Deno.env.get("SLACK_SIGNUPS_WEBHOOK_URL");
const RESEND_API_KEY = Deno.env.get("RESEND_API_KEY");
const NOTIFY_TO = Deno.env.get("NOTIFY_TO"); // your own inbox
const NOTIFY_FROM = Deno.env.get("NOTIFY_FROM"); // a sender on a domain verified in Resend

export default {
  fetch: withSupabase({ auth: "secret" }, async (req) => {
    const payload = (await req.json()) as InsertPayload;
    if (payload.type !== "INSERT" || payload.table !== "profiles") {
      return Response.json({ skipped: true });
    }

    const user = payload.record;
    const ft = user.first_touch;
    const source = ft?.utm_source ?? ft?.referrer ?? (ft ? "direct" : "unknown");
    const campaign = ft?.utm_campaign ? `, campaign ${ft.utm_campaign}` : "";
    const line = `New signup: ${user.email ?? user.id} from ${source}${campaign}`;

    const jobs: Promise<Response>[] = [];
    if (SLACK_URL) {
      jobs.push(
        fetch(SLACK_URL, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ text: line }),
        }),
      );
    }
    if (RESEND_API_KEY && NOTIFY_TO && NOTIFY_FROM) {
      jobs.push(
        fetch("https://api.resend.com/emails", {
          method: "POST",
          headers: { Authorization: `Bearer ${RESEND_API_KEY}`, "Content-Type": "application/json" },
          body: JSON.stringify({
            from: NOTIFY_FROM,
            to: [NOTIFY_TO],
            subject: line,
            text: `${line}\nFirst page: ${ft?.landing ?? "unknown"}`,
          }),
        }),
      );
    }

    // Log failures, never retry into a loop: a missed ping is better than a stuck queue.
    for (const r of await Promise.allSettled(jobs)) {
      if (r.status === "rejected") console.error("notify failed", r.reason);
      else if (!r.value.ok) console.error("notify failed", r.value.status, await r.value.text());
    }
    return Response.json({ ok: true });
  }),
};

The payload type is the one Database Webhooks send, so this function works whether you call it from the trigger below or from a webhook made in the dashboard.

The caller is the database, not a signed-in user. Supabase’s guide to securing Edge Functions covers exactly this case: for calls from pg_net, “disable verify_jwt and use auth: 'secret'”, which accepts a secret key on the apikey header. So in supabase/config.toml:

[functions.notify-signup]
verify_jwt = false

Put the four values in supabase/functions/.env, then push them and deploy with the commands from the secrets guide and the quickstart:

supabase secrets set --env-file supabase/functions/.env
supabase functions deploy notify-signup

Finally the trigger that calls it, with the function’s URL and a secret key (from Settings > API Keys) in Vault, as Supabase’s own scheduling example does:

select vault.create_secret('https://<project-ref>.supabase.co/functions/v1/notify-signup', 'notify_signup_url');
select vault.create_secret('<a secret key>', 'notify_signup_key');

create function public.call_notify_signup()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  perform net.http_post(
    url := (select decrypted_secret from vault.decrypted_secrets where name = 'notify_signup_url'),
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'notify_signup_key')
    ),
    body := jsonb_build_object(
      'type', 'INSERT',
      'table', tg_table_name,
      'schema', tg_table_schema,
      'record', to_jsonb(new),
      'old_record', null
    ),
    -- pg_net gives up after 2 seconds by default; leave room for a cold start.
    timeout_milliseconds := 5000
  );
  return new;
exception when others then
  raise warning 'signup notification skipped: %', sqlerrm;
  return new;
end;
$$;

create trigger on_profile_created_call_function
  after insert on public.profiles
  for each row execute function public.call_notify_signup();

Use step 2 or step 3, not both, or Slack hears about everyone twice.

What about Auth Hooks?

Supabase has a Before User Created hook, and it sounds like the right place. It is not. It runs before the insert (“this user will not be found in Postgres at the time the hook is called”) and it exists to allow or reject the signup. A message sent from it can announce a user who is then never created. Use it to block disposable domains; notify from the table.

Step 4: record where the signup came from

By the time auth.users gets a row, the visitor may have read three pages, and the UTM tags from the link they clicked are no longer in the address bar. So catch them on the first page, keep them, and hand them over at signup.

On every page of your site, before anything else:

// first-touch.js: keeps the first source this browser arrived with
(function () {
  try {
    if (localStorage.getItem("first_touch")) return;
    const q = new URLSearchParams(location.search);
    const touch = { landing: location.pathname, at: new Date().toISOString() };
    for (const key of ["utm_source", "utm_medium", "utm_campaign", "ref"]) {
      const value = q.get(key);
      if (value) touch[key] = value.slice(0, 100);
    }
    const referrer = document.referrer ? new URL(document.referrer).hostname : "";
    if (referrer && referrer !== location.hostname) touch.referrer = referrer;
    localStorage.setItem("first_touch", JSON.stringify(touch));
  } catch {
    // storage blocked: no source, but the page still works
  }
})();

Then pass it along when you create the user. options.data in signUp “is stored on the raw_user_meta_data column”, which is what the trigger in step 1 reads:

const firstTouch = JSON.parse(localStorage.getItem("first_touch") ?? "null");

const { data, error } = await supabase.auth.signUp({
  email,
  password,
  options: { data: { first_touch: firstTouch } },
});

Magic links work the same way: signInWithOtp takes the same data option for new users. OAuth does not. The signInWithOAuth options have no metadata field, so the profile row is created without a source and Slack says “unknown”. Fill it in when the user lands back in your app; the policy and column grant in step 1 allow exactly this, once, and only while it is empty:

const { data: { user } } = await supabase.auth.getUser();
const firstTouch = JSON.parse(localStorage.getItem("first_touch") ?? "null");
if (user && firstTouch) {
  await supabase.from("profiles").update({ first_touch: firstTouch }).eq("id", user.id).is("first_touch", null);
}

Three things to know before you rely on it:

  • Local storage belongs to one origin. If your landing page is example.com and the app is app.example.com, the app cannot read what the landing page saved. Carry the tags on your own links from one to the other, or read them on the app’s first page too.
  • Users can edit their own metadata. Supabase’s row level security guide warns that user_metadata “can be modified by authenticated end users”. A source is fine there. A plan, a role or a discount is not.
  • Tag your links, or much of it is “direct”. A visit with no tags and no referrer can only be filed as direct. The answer on UTM tags that disappear covers the redirects that drop them.

Now select first_touch ->> 'utm_source', count(*) from profiles group by 1 tells you which posts brought users, and each Slack message says it as it happens.

The shorter way with Nordva Launch

The part above that takes the most care is step 4: first touch across pages and subdomains, referrers that are really login providers, OAuth signups that arrive with nothing. Nordva Launch’s analytics script does that part. Add its script tag to your site and app, and after a signup call:

nordva("identify", { user_id: data.user.id, email });
nordva("goal", "signup");

The script already knows the visit’s first touch (UTM tags, else the referring site), so there is nothing to store yourself, and coming back from a login page such as Google’s does not become the source. When a person comes in somewhere a visit cannot describe, such as your mobile app, name it: nordva("identify", { email, source: "ios-app" }). The web analytics guide has the details, and the answer on connecting a signup to its first visit explains the idea.

Analytics works on every plan, including Free (10,000 events a month). Identified people appear under Contacts from Indie ($19 a month); on Free, Contacts holds waitlist signups only. With Stripe connected (every plan), a later payment traces back to that first visit, so you see which source brought paying users, not just signups.

What Launch does not do is post a Slack message for each signup in your own app: its webhooks cover waitlist signups, feedback and changelog events. Keep the trigger from step 2 for the ping. For a waitlist, the Slack guide shows the no-code route (webhooks need Indie).

Who should not use Launch for this: if you need the source inside your own database, to query it with SQL or to act on it in your app, keep step 4; Launch keeps the source in Launch. And if all you want is the ping, the trigger in step 2 is enough.

Start free, no card.

Questions

Can I put a Database Webhook directly on auth.users?
A Database Webhook is a trigger that calls pg_net, so a trigger on auth.users works the same way. Supabase recommends keeping your own data in a public table that references auth.users by its primary key, because other objects in the auth schema may change. Triggering on the profiles table keeps your code off Supabase's schema.
Should I use the Before User Created auth hook for this?
No. It runs before the user row exists and can reject the signup, so a notification sent from it could announce a user who is never created. It is for allowing or blocking signups. Notify after the row is written.
Will a Slack outage block signups?
Not with pg_net. The HTTP request only starts after the transaction commits, and the trigger in this guide catches its own errors, so the worst case is a missed message. A failing trigger that raises an error is different: it can block signups, which is why the examples catch everything.
Is user metadata safe to trust?
For attribution, yes. For permissions, no: Supabase warns that users can change their own user_metadata. Never base a role, a plan or a discount on it.
Related

Try it in your project

npx nordva@latest init

Free plan, no card, no invite.

Start free