Supabase setup
Connect SendBeam to Supabase: what you need, the steps, and the code.
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
- 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.
- Create a scoped API key. Settings → API keys, with
transactional:sendand 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 carriescontacts:writeorcampaigns:write— this one lives in your database, and the whole reason to scope it is the day it leaks. - Put the key in Vault, not in the trigger. Supabase Vault encrypts it at rest and keeps it out of
pg_dumpoutput, migrations and your repository. - Enable
pg_net. Database → Extensions. It is what lets Postgres make an outbound request. - Add the trigger. The example below sends a receipt when a row is inserted into
orders. - 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 5in 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/transactionalneedsto,subjectand 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_netdoes not retry. If the request fails, it fails once and the row is already committed — the trigger firedafter 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. Readingnet._http_responseis 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
429with aRetry-Afterheader rather than a silent drop. -
security defineris doing real work here. The function reads Vault, which the role performing the insert cannot. Pinsearch_path, as above, so the function resolves table names in a schema you control rather than one the caller chose.