Sync Supabase auth users into a Sendry audience
Use a Postgres trigger + database function to automatically add new Supabase auth users into a Sendry audience, with backfill for existing users.
When a user signs up in Supabase, you usually want them to end up in a Sendry audience so you can send onboarding sequences, broadcasts, or product announcements. This recipe wires that up with a Postgres trigger that fires on every insert into auth.users and calls Sendry's REST API via the pg_net extension.
No application code required — the sync happens entirely inside the database.
What you'll build
- A
secretsrow holding your Sendry API key and audience id. - A Postgres function
sync_user_to_sendry()that calls Sendry over HTTP. - A trigger on
auth.usersthat invokes the function on every new signup. - A backfill query that imports your existing users in one go.
Prerequisites
- Supabase project (any plan —
pg_netis enabled by default). - An existing Sendry audience. Create one in Sendry → Audiences and copy the id (e.g.
aud_abc123). - A Sendry API key with
full_access(so it can write to audiences).
Step 1: Store the API key
Don't hardcode keys in SQL — use Supabase Vault:
-- Run in the SQL editor
select vault.create_secret('sn_live_xxxxxxxxxxxx', 'sendry_api_key');
select vault.create_secret('aud_abc123', 'sendry_audience_id');
Now retrieve them in any function with vault.decrypted_secrets:
select decrypted_secret
from vault.decrypted_secrets
where name = 'sendry_api_key';
Step 2: Enable pg_net
create extension if not exists pg_net;
pg_net is async — it queues HTTP requests and processes them on a background worker, so your INSERT returns instantly even if Sendry is slow.
Step 3: Create the sync function
create or replace function public.sync_user_to_sendry()
returns trigger
language plpgsql
security definer
set search_path = public, vault
as $$
declare
api_key text;
audience_id text;
request_id bigint;
begin
select decrypted_secret into api_key
from vault.decrypted_secrets where name = 'sendry_api_key';
select decrypted_secret into audience_id
from vault.decrypted_secrets where name = 'sendry_audience_id';
if api_key is null or audience_id is null then
raise warning 'sendry_api_key or sendry_audience_id not set in Vault';
return new;
end if;
-- POST /v1/contacts with the audience_id attached
select net.http_post(
url := 'https://api.sendry.online/v1/contacts',
headers := jsonb_build_object(
'authorization', 'Bearer ' || api_key,
'content-type', 'application/json'
),
body := jsonb_build_object(
'email', new.email,
'first_name', coalesce(new.raw_user_meta_data->>'first_name', null),
'last_name', coalesce(new.raw_user_meta_data->>'last_name', null),
'audience_id', audience_id,
'properties', jsonb_build_object(
'supabase_user_id', new.id,
'signup_source', 'supabase'
)
)
) into request_id;
-- Optional: persist the request id for debugging
insert into public.sendry_sync_log (user_id, request_id)
values (new.id, request_id);
return new;
end;
$$;
Optional log table for observability:
create table if not exists public.sendry_sync_log (
id bigserial primary key,
user_id uuid not null,
request_id bigint not null,
created_at timestamptz not null default now()
);
You can inspect responses later with:
select * from net._http_response where id = <request_id>;
Step 4: Attach the trigger
create trigger on_auth_user_created_sync_sendry
after insert on auth.users
for each row execute function public.sync_user_to_sendry();
That's it — every new signup will now be queued for sync to Sendry.
Step 5: Backfill existing users
-- Re-fire the trigger logic for every existing user
do $$
declare
u record;
begin
for u in select * from auth.users loop
perform public.sync_user_to_sendry_row(u);
end loop;
end $$;
If you want a more controlled backfill (with retries, batching, etc.), export to CSV and use Sendry's bulk import instead:
copy (
select
email,
raw_user_meta_data->>'first_name' as first_name,
raw_user_meta_data->>'last_name' as last_name
from auth.users
) to '/tmp/users.csv' with csv header;
Then:
curl -X POST https://api.sendry.online/v1/contacts/bulk-import \
-H "Authorization: Bearer $SENDRY_API_KEY" \
-F "audience_id=aud_abc123" \
-F "file=@/tmp/users.csv"
Two-way sync: handle unsubscribes back into Supabase
When a contact unsubscribes in Sendry, mirror that into a Supabase column so your app stops triggering further emails. Configure a Sendry webhook for contact.unsubscribed:
- Sendry → Webhooks → Add → URL:
https://<project-ref>.supabase.co/functions/v1/sendry-unsub. - Deploy a small Edge Function (see Send from an Edge Function for scaffolding) that updates
auth.users.raw_app_meta_dataor your ownprofiles.unsubscribedcolumn.
Production hardening
- Idempotency: Sendry's
POST /v1/contactsis upsert-by-email — retries are safe. - Rate limit:
pg_nethas a built-in worker queue, but if you're importing >10k rows at once, batch via the bulk-import endpoint instead of firing 10k async POSTs. - Errors: query
net._http_responseperiodically (or via a scheduled function) and alert on non-2xx responses. - Secrets: rotate Vault-stored keys via
vault.update_secret(id, new_secret)— no schema changes required.
Troubleshooting
- Trigger doesn't fire: confirm the trigger exists with
select * from information_schema.triggers where event_object_table = 'users'. - Nothing in
net._http_response: the background worker may not be running — restart the project from Supabase Studio. 401 Unauthorizedfrom Sendry: the API key is wrong or missing thefull_accessscope.- Contacts created but not in the audience: confirm
audience_idis set in Vault and matches an existing audience.