SendBeam

Supabase setup

Connect SendBeam to Supabase: what you need, the steps, and the code.

View as Markdown

Via automation

Supabase gives you a Postgres database with triggers and outbound HTTP built in. That is everything needed to send email from the data itself — an order row appears and the receipt goes out, with no worker to deploy, no queue to run and nothing between your database and the send.

Before you start

  • A Supabase project
  • A verified sending domain in SendBeam
  • A SendBeam API key with transactional:send

Set it up

  1. Verify a sending domain. Settings → Domains. Transactional mail goes out over your own domain, so this has to be done first — the send is refused without it.
  2. Create a scoped API key. Settings → API keys, with transactional:send and nothing else. That permission sends to any address, which is the point: a customer getting a receipt is not a contact and does not have to be one. Do not reuse a key that also carries contacts:write or campaigns:write — this one lives in your database, and the whole reason to scope it is the day it leaks.
  3. Put the key in Vault, not in the trigger. Supabase Vault encrypts it at rest and keeps it out of pg_dump output, migrations and your repository.
  4. Enable pg_net. Database → Extensions. It is what lets Postgres make an outbound request.
  5. Add the trigger. The example below sends a receipt when a row is inserted into orders.
  6. Check it worked. Insert a row and look at SendBeam → the workspace’s activity, then select * from net._http_response order by created desc limit 5 in Supabase for the response we sent back.

Code

Store the key

SQL editor, once

select vault.create_secret('sb_live_your_key_here', 'sendbeam_api_key');

Send a receipt when an order is created

SQL editor

create or replace function public.send_order_receipt()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
  v_key text;
begin
  select decrypted_secret into v_key
    from vault.decrypted_secrets
   where name = 'sendbeam_api_key';

  perform net.http_post(
    url     := 'https://sendbeam.io/api/v1/transactional',
    headers := jsonb_build_object(
                 'Content-Type', 'application/json',
                 'x-api-key', v_key
               ),
    body    := jsonb_build_object(
                 'to',      new.customer_email,
                 'subject', 'Your order ' || new.reference,
                 'html',    '<p>Thanks for your order.</p>'
                            || '<p>Reference: <strong>' || new.reference || '</strong><br>'
                            || 'Total: ' || to_char(new.total, 'FM999,999.00') || '</p>'
               )
  );

  return new;
end;
$$;

create trigger orders_send_receipt
  after insert on public.orders
  for each row execute function public.send_order_receipt();

net.http_post returns immediately with a request id and the request is made in the background, so the insert is never held up waiting for us and never fails because we were slow.

Map the row explicitly

The three fields above are the whole contract: to, subject, and html or text. Everything else is optional — replyTo, a from of { "name": ..., "email": ... }, and up to ten custom headers. Build them from the row the same way:

body := jsonb_build_object(
  'to',      jsonb_build_array(jsonb_build_object('email', new.customer_email, 'name', new.customer_name)),
  'subject', 'Your order ' || new.reference,
  'text',    'Thanks for your order. Reference: ' || new.reference,
  'replyTo', '[email protected]',
  'headers', jsonb_build_object('X-Order-Reference', new.reference)
)

Send text on its own and we build the HTML from it, which is usually the right choice for a receipt.

Things to know

  • The Database Webhooks UI will not do this. It is worth being clear, because it looks like it should. That screen sends a fixed envelope — { "type": "INSERT", "table": "orders", "record": { ... } } — and lets you add headers but not change the body. /api/v1/transactional needs to, subject and a body, so the envelope is rejected as a bad request. Writing the trigger yourself, as above, is what lets you decide the payload. If you would rather use the UI, put a Supabase Edge Function in between to reshape the envelope, and point the webhook at that.

  • A Supabase retry is not a SendBeam retry. pg_net does not retry. If the request fails, it fails once and the row is already committed — the trigger fired after insert, so nothing is rolled back and nobody is told. For mail that genuinely must arrive, write the send to a table, retry from a scheduled job, and mark the row when we return a 2xx. Reading net._http_response is how you find out what happened.

  • We refuse some addresses on purpose. An address that has hard bounced or reported a previous email as spam is not sent to again, and the response says which and why. Unsubscribed is different: someone who opted out of marketing still gets their receipt, because the opt-out does not cover mail they asked for.

  • Limits. Ten recipients per request, a 500-character subject, and 500 KB of body. Over the hourly send allowance for your plan you get a 429 with a Retry-After header rather than a silent drop.

  • security definer is doing real work here. The function reads Vault, which the role performing the insert cannot. Pin search_path, as above, so the function resolves table names in a schema you control rather than one the caller chose.

← Back to the Supabase integration