← Back to blog

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.

Updated November 5, 2026
Supabase pg_cron Scheduled Edge Function Email with Rovela (2026): Vault + Idempotency-Key per Window

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:

  1. Stores the Edge Function invoke credential in Supabase Vault (not in a public migration as plaintext)
  2. Stores ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients as Edge Function secrets — never in the browser, never in Git
  3. Schedules cron.schedule + net.http_post (or the Dashboard “Edge Function” Job target) on a digest cadence
  4. Aggregates a digest / report from Postgres for the current window
  5. Sends via Rovela with a window-scoped Idempotency-Key
  6. 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:

What Supabase Cron does vs what Rovela sends

ConcernOwner
Five-field cron (or sub-minute N seconds where supported) + Job nameSupabase Cron / pg_cron (Cron, quickstart)
net.http_post to Edge Function URLpg_net (pg_net, schedule functions)
Project URL + invoke secret for the HTTP callSupabase Vault
Edge Function runtime secrets (ROVELA_API_KEY, …)Edge Function secrets via Dashboard or supabase secrets set
Query Postgres for the windowYour Edge Function (service role / SUPABASE_DB_URL / SQL)
Render HTML + textYour function
POST /emails, DKIM, queue, bouncesRovela
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 needs pg_net. Create Jobs in Integrations → Cron or with cron.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_url and a secret key (not the publishable key) with vault.create_secret, then read vault.decrypted_secrets inside the scheduled SQL (schedule functions).
  • Do not put ROVELA_API_KEY in the Cron SQL / pg_net body. pg_net writes request headers (including decrypted secrets) to net.http_request_queue until 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 apikey header; accept it with verify_jwt disabled and auth: '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_details records history; clean old rows with a separate cleanup Job — they are not auto-purged (quickstart).
  • Secrets stay server-side. Prefer a Rovela sending_access key — never ship it in a browser bundle or public Git (Authentication).
  • Outbound SMTP is a poor fit. Prefer HTTPS POST /emails on 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

  1. A verified sending domain in Rovela. Unverified from domains are refused with 400 (Quick Start).
  2. An API key shaped like re_…. Prefer sending_access for the Edge Function (Authentication).
  3. A Supabase project with Cron (pg_cron) and pg_net available, plus permission to create Edge Functions and secrets.
  4. Edge Function secrets for ROVELA_API_KEY, MAIL_FROM, DIGEST_TO (and optionally DIGEST_KIND).
  5. Vault secrets for project_url and the invoke secret_key used by net.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:

KeyUse 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

SymptomLikely causeFix
Cron shows success but no mailFunction returned 500 / Rovela 4xx; Cron only sees pg_net acceptCheck Edge Function logs; inspect Rovela dashboard / full_access message events
401 from RovelaBad or revoked key, or wrong headerAuthorization: Bearer re_…; recreate sending_access key
400 from RovelaUnverified from domain, missing body, >50 recipientsVerify domain; send html and/or text; split recipients
429 from RovelaRate limitBack off; retry with the same Idempotency-Key
Function never invokedJob inactive, wrong name, pg_net disabledcron.job / Dashboard Active toggle; enable pg_net
Duplicate digestsKey too coarse / missingUse window-scoped keys; never omit Idempotency-Key on Cron-driven sends
Secret visible in DBRovela key put in Vault + pg_net headersMove Rovela key to Edge Function secrets only

Production checklist

  • Rovela domain verified; from uses that domain
  • Edge Function uses sending_access (re_…); never full_access in Cron paths
  • ROVELA_API_KEY only in Edge Function secrets — not in Cron SQL, Git, or client code
  • Vault holds project_url + invoke secret_key for net.http_post
  • Scheduled function authenticates service-to-service (verify_jwt off + 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.