What Supabase's run history can miss
Supabase Cron is built on pg_cron, and Supabase's docs say:
The extension creates a cron schema in your database and all Jobs are stored on the cron.job table. Every Job's run and its status is recorded on the cron.job_run_details table.
When the job calls an Edge Function, that status belongs to the job's SQL, which only queues the call. The call goes through pg_net, and Supabase's pg_net docs say “HTTP requests are not started until the transaction is committed.” So the job's run is over before your function starts, and the history can say it succeeded while the function failed. That's why the report line goes inside the function, at its end.
A run that never starts leaves no row at all. Supabase's pg_cron debugging guide says “pg_cron operates as the pg_cron scheduler process within Postgres.” If that process stops, no job runs. Sereno knows when each run is due, so when no report arrives in time, it marks the run missed and alerts you.
Before you start
- A cron job that calls an Edge Function. The report line runs inside the function, so a job that only runs SQL has nowhere to put it.
- A check in Sereno for this job, on the same schedule, in UTC. pg_cron's README says its scheduler's time zone “defaults to GMT”, and Supabase's examples are written in GMT. Make it with New check on your dashboard, which takes the same five-field cron expression, or let your AI agent set it up.
- A grace period of 15 minutes plus the job's longest run time. Grace is how late a run's report can come before the run counts as missed, and the report line below sends its report only when the job ends.
- Your project's report address, the value for ALERTS_URL. It looks like
https://asksereno.com/k/PROJECT_KEY, with no check slug at the end. The check's page shows it under This project's report address, ready to copy.
Add the report line
To save your project's report address with the Supabase CLI, first run this in a terminal, then paste the address and press Enter. It waits without a prompt, and keeps the address out of your shell's history.
read -r ALERTS_URLread -r ALERTS_URL
Save this project's report address as a secret named ALERTS_URL in Supabase (Edge Functions → Secrets), or with the Supabase CLI, from a terminal where ALERTS_URL already holds it.
supabase secrets set ALERTS_URL="$ALERTS_URL"supabase secrets set ALERTS_URL="$ALERTS_URL"
Run the CLI line in that same terminal, in your Supabase project's folder after supabase link, or add --project-ref and your project's ref.
Report from inside the function your schedule calls, at its end, so a run that fails says so. A report sent after calling it would say ok even when the function failed.
// supabase/functions/FUNCTION_NAME/index.ts: the edge function your schedule calls. Deno.serve(async () => { const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } }); // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (Deno.env.get("ALERTS_URL") ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }// supabase/functions/FUNCTION_NAME/index.ts: the edge function your schedule calls. Deno.serve(async () => { const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } }); // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (Deno.env.get("ALERTS_URL") ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }
If your function already has its own Deno.serve, keep it, and move its work into the try in place of runJob(). The cron job's SQL stays as it is.
CHECK_SLUG stands for your check's slug, which Sereno makes from the check's name. The report line adds it to ALERTS_URL itself, so the variable holds only your project's report address. On the check's page, these lines have the slug filled in.
Supabase's docs say “Your functions read a new secret immediately, so you don't need to redeploy.” The function's new code still needs a deploy. Put the function's folder name, under supabase/functions, in place of FUNCTION_NAME:
supabase functions deploy FUNCTION_NAMEsupabase functions deploy FUNCTION_NAME
The report takes a moment. It tries again only while it can't reach Sereno, for up to about 2 minutes, and that time counts toward the function's time limits.
Send a test report
From a terminal on your own computer, run this. The check's page in Sereno shows this line with your project's key and your check's slug filled in, under Check it can reach us.
curl -fsS -m 10 https://asksereno.com/k/PROJECT_KEY/CHECK_SLUG/testcurl -fsS -m 10 https://asksereno.com/k/PROJECT_KEY/CHECK_SLUG/test
It prints OK. On the check's page, Test report gets a tick. A test report never changes the check's status.
That proves the address and the slug are right. Supabase's side shows on the first scheduled run: Real run gets a tick, and the check shows OK, or Failing if the run failed.
If it doesn't work
The test fails
- The test prints
curl: (22) The requested URL returned error: 404. Sereno doesn't know that address. Open the project's page in Sereno: if the test shows under Reports for checks that don't exist, the slug is wrong. If it doesn't show there, the project key is wrong. - The test prints error 429. That check got more than 60 reports in a minute: wait a minute and try again. An error of 500 or above means Sereno couldn't answer just then, so try again in a few minutes. Every answer a report address gives is in the docs.
The test works, but scheduled runs don't report
- The function's logs show “ALERTS_URL is not set: this run wasn't reported”. Add the ALERTS_URL secret in Edge Functions → Secrets. Functions read it right away.
- Runs show as missed, but the function's logs show the job ran. The slug in your code may not match the one you tested, and the report line ignores a 404 without logging it. Open the project's page in Sereno: a wrong slug shows under Reports for checks that don't exist. Fix CHECK_SLUG in the code, or ALERTS_URL if nothing shows there, then deploy again.
- No report at the scheduled time. To see whether the job ran, use this query from Supabase's docs in the SQL editor, with your job's name in place of JOB_NAME:No recent rows means the job didn't run. Supabase's pg_cron debugging guide shows how to check the scheduler and restart it. Each row has the run's status, and return_message holds any error.
select * from cron.job_run_details where jobid = (select jobid from cron.job where jobname = 'JOB_NAME') order by start_time desc limit 10;
select * from cron.job_run_details where jobid = (select jobid from cron.job where jobname = 'JOB_NAME') order by start_time desc limit 10; - Jobs fail at busy times. Supabase's docs say “For best performance, we recommend no more than 8 Jobs run concurrently. Each Job should run no more than 10 minutes.” Spread their schedules out.
The short version of this setup, with the other platforms', is in Sereno's setup recipes.
Supabase's and pg_cron's docs quoted as read on October 26, 2026.
More guides
- Ask your AI agent why a cron job failed
- Monitor Vercel cron jobs
- Monitor GitHub Actions scheduled workflows
- Get an alert when a cron job fails or doesn't run
- Healthchecks.io alternative with AI agent setup
- Cronitor alternative with 200 free checks
New here? Sign up with your email. Free: up to 200 checks, 50 alert emails a day, and phone alerts through ntfy.