Supabase pg_cron Scheduled Edge Function Email with Rovela (2026): Vault + Idempotency-Key per Window
Wire Supabase Cron (pg_cron) and pg_net to an Edge Function that sends digests via Rovela POST /emails: Vault for invoke secrets, Edge Function secrets for sending_access, and a window-scoped Idempotency-Key. Not Auth/SMTP — scheduled jobs only.
Supabase Cron (pg_cron) owns the schedule — Rovela delivers the mail
Supabase Cron is a Postgres module built on the pg_cron extension. You schedule recurring Jobs with classic five-field cron (or, on supported Postgres builds, sub-minute forms like 30 seconds), then run a SQL snippet, a database function, an HTTP request, or a Supabase Edge Function. Pair Cron with pg_net and you can net.http_post from SQL on a cadence — including a POST that wakes an Edge Function to send mail.
That shape fits digests, ops reports, unpaid-invoice reminders, and other transactional messages whose body depends on live Postgres state at fire time — signup counts, failed webhook rows, trial expiries — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your Edge Function (or other server) calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Supabase owns the Cron schedule, pg_net invoke, Vault secrets for the function call, and Edge Function secrets for sending_access. Your function owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Supabase Cron (pg_cron) → Edge Function → Rovela that:
- Stores the Edge Function invoke credential in Supabase Vault (not in a public migration as plaintext)
- Stores
ROVELA_API_KEY(sending_access),MAIL_FROM, and recipients as Edge Function secrets — never in the browser, never in Git - Schedules
cron.schedule+net.http_post(or the Dashboard “Edge Function” Job target) on a digest cadence - Aggregates a digest / report from Postgres for the current window
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives Cron retries, overlapping ticks, and manual re-invokes without duplicate mail
Rovela does not create Cron Jobs, enable pg_cron / pg_net, or manage Vault / Edge Function secrets. Supabase owns schedule → HTTP invoke → function run. Your function owns query → map fields → POST /emails. Rovela owns delivery.
This is not the Auth / custom-SMTP path. That angle is covered separately in Send Transactional Email with Supabase + Rovela (OTP, magic links, Auth hooks). This post is scheduled digest / report jobs only.
Distinct from:
- Kubernetes CronJob Email —
batch/v1CronJob → Job Pod start→exit - DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob - Heroku Scheduler Email — coarse Scheduler dynos
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB + App Settings
- GCP Cloud Scheduler Email — Cloud Scheduler HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions Email — Netlify UTC cron
- Fly Scheduled Machines Email — Fly Machine schedules
- Render Cron Transactional Email — Render Cron Job process
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — HTTP
GETto a production route - GitHub Actions Transactional Email — workflow / CI notify
- Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known - Supabase Transactional Email API — Auth / custom SMTP / OTP — not pg_cron digests
What Supabase Cron does vs what Rovela sends
| Concern | Owner |
|---|---|
Five-field cron (or sub-minute N seconds where supported) + Job name | Supabase Cron / pg_cron (Cron, quickstart) |
net.http_post to Edge Function URL | pg_net (pg_net, schedule functions) |
| Project URL + invoke secret for the HTTP call | Supabase Vault |
Edge Function runtime secrets (ROVELA_API_KEY, …) | Edge Function secrets via Dashboard or supabase secrets set |
| Query Postgres for the window | Your Edge Function (service role / SUPABASE_DB_URL / SQL) |
| Render HTML + text | Your function |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = supabase-cron-daily-digest:{yyyy-mm-dd} (or hour / minute bucket) | Your function |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Supabase Cron + Edge Function constraints that shape the email job
From Supabase docs, treat these as hard requirements for a mail-sending Cron Job:
- Enable the extensions. Cron uses
pg_cron; invoking an Edge Function from SQL needspg_net. Create Jobs in Integrations → Cron or withcron.schedule(Cron). - Job names are case-sensitive and immutable. Creating a second Job with the same name upserts/overwrites the first (quickstart).
- Prefer Vault for invoke credentials. Store
project_urland a secret key (not the publishable key) withvault.create_secret, then readvault.decrypted_secretsinside the scheduled SQL (schedule functions). - Do not put
ROVELA_API_KEYin the Cron SQL /pg_netbody.pg_netwrites request headers (including decrypted secrets) tonet.http_request_queueuntil the request sends — any DB login role that can read that queue can see them (schedule functions caution). Keep the Rovela key only in Edge Function secrets and call Rovela from Deno. - Disable JWT verification for the scheduled function path (or use secret auth). The scheduled invoke posts your secret on the
apikeyheader; accept it withverify_jwtdisabled andauth: 'secret'(or equivalent service-to-service pattern) as documented for scheduled Edge Functions (schedule functions). - Keep Jobs short. Supabase recommends no more than ~8 Jobs concurrent and each Job under ~10 minutes (Cron). Query + one HTTPS send (or a small batch) then return.
- Inspect runs.
cron.job_run_detailsrecords history; clean old rows with a separate cleanup Job — they are not auto-purged (quickstart). - Secrets stay server-side. Prefer a Rovela
sending_accesskey — never ship it in a browser bundle or public Git (Authentication). - Outbound SMTP is a poor fit. Prefer HTTPS
POST /emailson 443 (Email API vs SMTP).
When to use Supabase Cron + POST /emails (not scheduled_at)
Use Cron → Edge Function → live send when:
- The HTML must include fresh metrics from Postgres at fire time
- You already run the app on Supabase and want the schedule next to the data (zero extra scheduler product)
- Five-field cron (or sub-minute where available) is enough
- One (or a few) transactional messages go to ops / founder / customer addresses looked up at run time
Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running job (Scheduled Email Sending). Cron and scheduled_at solve different problems; this post is the Supabase Cron path.
Use Auth hooks / custom SMTP only for Auth lifecycle mail — see Supabase Transactional Email API.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the Edge Function (Authentication). - A Supabase project with Cron (
pg_cron) andpg_netavailable, plus permission to create Edge Functions and secrets. - Edge Function secrets for
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(and optionallyDIGEST_KIND). - Vault secrets for
project_urland the invokesecret_keyused bynet.http_post.
Store invoke secrets in Vault
Do this once (SQL Editor or migration). Replace the project ref and secret key with your own — prefer a secret key, not the publishable key (schedule functions):
select vault.create_secret(
'https://YOUR_PROJECT_REF.supabase.co',
'project_url',
'Supabase API URL for Cron → Edge Function invokes'
);
select vault.create_secret(
'YOUR_SUPABASE_SECRET_KEY',
'secret_key',
'Secret key used as apikey when Cron invokes Edge Functions'
);
Rotate with vault.update_secret when keys change (Vault). Restrict who can SELECT from vault.decrypted_secrets.
Edge Function secrets for Rovela
Set production secrets (Dashboard → Edge Function Secrets, or CLI). Names must not start with SUPABASE_ (secrets):
supabase secrets set \
ROVELA_API_KEY=re_your_sending_access_key \
MAIL_FROM='Acme Reports <reports@mail.example.com>' \
DIGEST_TO='founders@example.com' \
DIGEST_KIND=daily
Locally, put the same keys in supabase/functions/.env (gitignored) and load them with supabase start / supabase functions serve. Prefer sending_access on production senders — never full_access (Authentication). Never log the API key. New secrets are readable by deployed functions without a redeploy (secrets).
Edge Function: window-scoped Idempotency-Key + POST /emails
Create the function:
supabase functions new daily-digest
Example supabase/functions/daily-digest/index.ts:
// supabase/functions/daily-digest/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name: string): string {
const v = Deno.env.get(name)
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()): string {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
function utcHourKey(d = new Date()): string {
return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}
function windowKey(kind: string): string {
if (kind === 'hourly') return `supabase-cron-hourly-ops:${utcHourKey()}`
if (kind === 'fifteen') {
const d = new Date()
const bucket = Math.floor(d.getUTCMinutes() / 15) * 15
const hh = String(d.getUTCHours()).padStart(2, '0')
const mm = String(bucket).padStart(2, '0')
return `supabase-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`
}
return `supabase-cron-daily-digest:${utcDayKey()}`
}
async function buildDailyDigest(day: string) {
// Prefer querying your tables with the service-role client.
// Injected SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY (legacy) or
// SUPABASE_SECRET_KEYS (newer) are available in Edge Functions.
const url = Deno.env.get('SUPABASE_URL')!
const serviceKey =
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ??
(() => {
try {
const keys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS') ?? '{}')
return keys.default as string | undefined
} catch {
return undefined
}
})()
let stats = { signups: 0, paid: 0, incidents: 0, failedJobs: 0 }
if (url && serviceKey) {
const supabase = createClient(url, serviceKey)
// Replace with your real aggregations for the window
const { count: signups } = await supabase
.from('profiles')
.select('*', { count: 'exact', head: true })
.gte('created_at', `${day}T00:00:00.000Z`)
.lt('created_at', `${day}T23:59:59.999Z`)
stats = {
signups: signups ?? 0,
paid: 3,
incidents: 1,
failedJobs: 0,
}
} else {
// Demo fallback when tables are not wired yet
stats = { signups: 12, paid: 3, incidents: 1, failedJobs: 0 }
}
const subject = `Acme daily digest — ${day}`
const text = [
`Signups: ${stats.signups}`,
`Paid: ${stats.paid}`,
`Open incidents: ${stats.incidents}`,
`Failed jobs: ${stats.failedJobs}`,
].join('\n')
const html = `
<h1>Daily digest — ${day}</h1>
<ul>
<li>Signups: <b>${stats.signups}</b></li>
<li>Paid: <b>${stats.paid}</b></li>
<li>Open incidents: <b>${stats.incidents}</b></li>
<li>Failed jobs: <b>${stats.failedJobs}</b></li>
</ul>
<p><a href="https://app.example.com/admin">Open admin</a></p>
`
return { subject, html, text, stats }
}
async function sendTransactional(opts: {
to: string | string[]
subject: string
html: string
text: string
idempotencyKey: string
tags: Record<string, string>
}) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': opts.idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
tags: opts.tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${(body as { message?: string }).message ?? 'send failed'}`)
}
return body as { object: string; id: string }
}
Deno.serve(async (req) => {
// Optional: verify the Cron invoke secret on apikey / Authorization
// when verify_jwt is disabled — see Supabase service-to-service docs.
try {
const kind = Deno.env.get('DIGEST_KIND') || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const idempotencyKey = windowKey(kind)
console.log(JSON.stringify({ kind, day, idempotencyKey /* never log ROVELA_API_KEY */ }))
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `supabase-cron-${kind}-digest`,
day,
platform: 'supabase-pg-cron',
},
})
return Response.json({ ok: true, emailId: result.id, idempotencyKey })
} catch (err) {
console.error(err)
return Response.json(
{ ok: false, error: err instanceof Error ? err.message : 'failed' },
{ status: 500 },
)
}
})
Deploy:
supabase functions deploy daily-digest --no-verify-jwt
Use --no-verify-jwt (or the Dashboard equivalent) when Cron authenticates with your Vault secret on the apikey header instead of an end-user JWT (schedule functions). Still verify the secret inside the function if you expose it on the public Functions URL.
Rovela’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API). That matters on Supabase Cron because:
pg_cron/ Dashboard may re-fire while you debug the function- Sub-minute or overlapping schedules can re-enter the same calendar window
- Manual
curl/ Dashboard “Run” can overlap a scheduled invoke - Edge Function retries after a transient network blip would otherwise double-send
Recommended keys:
| Key | Use when |
|---|---|
supabase-cron-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
supabase-cron-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
supabase-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | Every-15-minutes pulse (mm floored to 00/15/30/45) |
supabase-cron-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
supabase-cron-customer-digest:{userId}:{yyyy-mm-dd} | Per-user digests (one key per recipient window) |
If you mail many personalized digests, loop with a fresh key per recipient (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)). Cap concurrency; on 429, back off and retry with the same key. Keep wall time short so the Cron Job finishes cleanly.
Schedule the Edge Function with pg_cron + pg_net
Daily at 13:00 UTC example (adjust the cron expression to your timezone needs — pg_cron schedules are typically interpreted in the database timezone / UTC; confirm in your project):
select
cron.schedule(
'invoke-daily-digest',
'0 13 * * *', -- 13:00 daily
$$
select
net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
|| '/functions/v1/daily-digest',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')
),
body := jsonb_build_object('time', now(), 'kind', 'daily'),
timeout_milliseconds := 10000
) as request_id;
$$
);
Every-minute smoke test (remove after validation):
select
cron.schedule(
'invoke-daily-digest-smoke',
'* * * * *',
$$
select
net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
|| '/functions/v1/daily-digest',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')
),
body := jsonb_build_object('time', now()),
timeout_milliseconds := 5000
) as request_id;
$$
);
Or create the Job in the Dashboard: Integrations → Cron → Create job, choose schedule, target Supabase Edge Function, pick daily-digest (quickstart).
Unschedule when done testing:
select cron.unschedule('invoke-daily-digest-smoke');
List recent runs:
select *
from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'invoke-daily-digest')
order by start_time desc
limit 20;
Optional: call Rovela from SQL? Prefer the Edge Function
You can net.http_post straight to https://api.rovela.dev/emails from Cron SQL with the Bearer token in Vault. Avoid that for production mail. The decrypted Bearer lands in net.http_request_queue while the request waits (schedule functions caution), and you lose TypeScript aggregation, structured logging, and easier secret rotation via supabase secrets set. Keep Cron → Edge Function → Rovela as the default.
Verify a single send without waiting for Cron
curl -i -X POST \
"https://YOUR_PROJECT_REF.supabase.co/functions/v1/daily-digest" \
-H "Content-Type: application/json" \
-H "apikey: YOUR_SUPABASE_SECRET_KEY" \
-d '{"manual":true}'
Expect { "ok": true, "emailId": "…", "idempotencyKey": "supabase-cron-daily-digest:…" }. Re-run the same day with the same key — Rovela should return the same email id within 24 hours instead of a second delivery (Send Email API).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Cron shows success but no mail | Function returned 500 / Rovela 4xx; Cron only sees pg_net accept | Check Edge Function logs; inspect Rovela dashboard / full_access message events |
401 from Rovela | Bad or revoked key, or wrong header | Authorization: Bearer re_…; recreate sending_access key |
400 from Rovela | Unverified from domain, missing body, >50 recipients | Verify domain; send html and/or text; split recipients |
429 from Rovela | Rate limit | Back off; retry with the same Idempotency-Key |
| Function never invoked | Job inactive, wrong name, pg_net disabled | cron.job / Dashboard Active toggle; enable pg_net |
| Duplicate digests | Key too coarse / missing | Use window-scoped keys; never omit Idempotency-Key on Cron-driven sends |
| Secret visible in DB | Rovela key put in Vault + pg_net headers | Move Rovela key to Edge Function secrets only |
Production checklist
-
Rovela domain verified;
fromuses that domain -
Edge Function uses
sending_access(re_…); neverfull_accessin Cron paths -
ROVELA_API_KEYonly in Edge Function secrets — not in Cron SQL, Git, or client code -
Vault holds
project_url+ invokesecret_keyfornet.http_post -
Scheduled function authenticates service-to-service (
verify_jwtoff + secret check, or documented equivalent) -
Every Cron-driven send sets a window-scoped
Idempotency-Key -
Job finishes well under the ~10 minute guidance; cleanup Job for
cron.job_run_details - Distinct from Auth OTP / magic-link flows (Supabase Transactional Email API)
Summary
Supabase Cron (pg_cron) plus pg_net gives you a Postgres-native scheduler that can wake an Edge Function on a five-field (or sub-minute) cadence. Put invoke credentials in Vault, put Rovela sending_access in Edge Function secrets, aggregate live state in Deno, and call POST https://api.rovela.dev/emails with a window-scoped Idempotency-Key. Rovela queues, signs, and delivers — Supabase owns the clock.
Next: wire Auth lifecycle mail separately with Supabase + Rovela transactional email, or queue a known body for later with Scheduled Email Sending.