Sendry Docs
Recipes

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

  1. A secrets row holding your Sendry API key and audience id.
  2. A Postgres function sync_user_to_sendry() that calls Sendry over HTTP.
  3. A trigger on auth.users that invokes the function on every new signup.
  4. A backfill query that imports your existing users in one go.

Prerequisites

  • Supabase project (any plan — pg_net is 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:

  1. Sendry → Webhooks → Add → URL: https://<project-ref>.supabase.co/functions/v1/sendry-unsub.
  2. Deploy a small Edge Function (see Send from an Edge Function for scaffolding) that updates auth.users.raw_app_meta_data or your own profiles.unsubscribed column.

Production hardening

  • Idempotency: Sendry's POST /v1/contacts is upsert-by-email — retries are safe.
  • Rate limit: pg_net has 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_response periodically (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 Unauthorized from Sendry: the API key is wrong or missing the full_access scope.
  • Contacts created but not in the audience: confirm audience_id is set in Vault and matches an existing audience.

On this page