Vercel Cron Digest Email with Rovela (2026): CRON_SECRET + POST /emails + Idempotency-Key
Secure a Vercel Cron App Router route with CRON_SECRET, build a fresh daily/weekly digest or ops report from your DB, and send it via Rovela POST /emails with Idempotency-Key scoped to the UTC run window. Secrets in project env. Distinct from Cloudflare Workers transactional, AI-agent HTTP send, and Rovela scheduled_at.
Vercel Cron builds the digest — Rovela delivers it
Vercel Cron Jobs call a path on your production deployment on a schedule. Your App Router (or other) route handler queries the database, renders a daily / weekly digest or ops report, and posts it through Rovela’s HTTP API. That is the right split when the body depends on state at send time — counts, failed jobs, new signups, unpaid invoices — rather than content you can freeze hours earlier with scheduled_at.
Rovela queues, DKIM-signs, and delivers when your cron route calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Vercel owns the schedule and the GET invocation. Your app owns auth (CRON_SECRET), aggregation, and a stable Idempotency-Key per run window. Rovela only delivers.
This guide wires a secured cron route that:
- Rejects callers that do not present
Authorization: Bearer ${CRON_SECRET}(Managing Cron Jobs) - Aggregates a digest / report from your own data for the current UTC window
- Sends one (or a few) transactional messages via Rovela with
Idempotency-Keykeyed to that window - Keeps
ROVELA_API_KEY,CRON_SECRET, andMAIL_FROMin Vercel project env — never in the client bundle
Rovela does not create Vercel Cron jobs, set CRON_SECRET, or read your database. Vercel owns the schedule and the production GET. Your route owns verify → aggregate → map fields. Rovela owns POST /emails.
Distinct from:
- Email from Cloudflare Workers — Worker + Wrangler secrets, not Vercel Cron
- AI agents send email over HTTP — agent / Cursor scaffolding for ad-hoc
curl, not a production schedule - Scheduled Email Sending — Rovela
scheduled_atwhen the body is known ahead of time - Sending Email from Next.js — Route Handlers / Server Actions for user-triggered mail, not cron digests
What Vercel Cron does vs what Rovela sends
| Concern | Owner |
|---|---|
Cron expression + production-only GET to your path | Vercel (Cron Jobs) |
CRON_SECRET → Authorization: Bearer … on each invocation | Vercel + your route check |
| Querying Postgres / KV / Stripe for the window | Your app |
| Rendering HTML + text digest / report | Your app |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = digest-{kind}-{yyyy-mm-dd} (or week id) | Your cron route |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
When to use Cron + POST /emails (not scheduled_at)
Use Vercel Cron → live send when:
- The HTML must include fresh metrics (signups today, open incidents, MRR delta)
- You would otherwise keep a worker scanning a “due digests” table every minute
- One message goes to ops / founder / customer addresses that are fixed or looked up at run time
Use Rovela scheduled_at when the body is already correct and you only need “deliver at 09:00 in the user’s zone” (Scheduled Email Sending). Cron and scheduled_at solve different problems; this post is the Cron path.
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer something likereports.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the cron function (Authentication). - A Vercel project with a production deployment. Cron jobs run on production deployments, not preview (Getting started with cron jobs).
- Environment variables on the project (Production):
CRON_SECRET,ROVELA_API_KEY,MAIL_FROM, plus whatever DB / API credentials the digest needs. - Redeploy after adding env vars so the production function sees them.
Environment
In the Vercel dashboard (Project → Settings → Environment Variables) or via the CLI:
# Generate a long random secret (Vercel recommends ≥ 16 characters)
openssl rand -hex 32
# Add as CRON_SECRET for Production, then redeploy
# Rovela
ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com' # or look up recipients in code
Do not prefix these with NEXT_PUBLIC_. Never commit the key. Rotate Rovela keys by create → deploy → revoke (Authentication). Rotating CRON_SECRET takes effect on the next deployment that includes the new value; update and redeploy together.
Declare the schedule in vercel.json
Vercel invokes cron jobs with an HTTP GET to the path you declare. Schedules are UTC cron expressions (Cron Jobs).
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/daily-digest",
"schedule": "0 13 * * *"
}
]
}
0 13 * * * is 13:00 UTC every day. Pick a window that matches when your metrics for “yesterday” or “today so far” are stable. Hobby plans have stricter frequency limits than Pro — check Vercel’s Cron docs for your plan before assuming hourly runs.
Cron invocations include User-Agent: vercel-cron/1.0 and an x-vercel-cron-schedule header with the expression that fired. They do not follow redirects — if you enable trailingSlash, make the cron path match exactly (Troubleshooting).
Secure the route with CRON_SECRET
Anyone who guesses /api/cron/daily-digest can hit a public URL. When CRON_SECRET is set on the project, Vercel sends it as Authorization: Bearer <value> on every cron request. Compare it before any DB work or send (Managing Cron Jobs).
// app/api/cron/daily-digest/route.ts
import { NextRequest } from 'next/server'
export const dynamic = 'force-dynamic'
// Optional: raise if your aggregation needs more than the default function duration
// export const maxDuration = 60
function unauthorized() {
return new Response('Unauthorized', { status: 401 })
}
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET
const auth = request.headers.get('authorization')
if (!secret || auth !== `Bearer ${secret}`) {
return unauthorized()
}
// … aggregate + send (below)
return Response.json({ ok: true })
}
The !secret guard matters: without it, a missing env var would accept Bearer undefined. Exclude /api/cron from middleware that redirects unauthenticated browsers to /login — cron does not follow that redirect.
Build the digest, then POST /emails
Keep aggregation in your own modules. Below is a minimal send helper and a window-scoped idempotency key. 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).
// lib/rovela.ts
import 'server-only'
type SendInput = {
to: string | string[]
subject: string
html: string
text: string
idempotencyKey: string
tags?: Record<string, string>
}
export async function sendTransactional(input: SendInput) {
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': input.idempotencyKey,
},
body: JSON.stringify({
from: process.env.MAIL_FROM,
to: input.to,
subject: input.subject,
html: input.html,
text: input.text,
tags: input.tags,
}),
cache: 'no-store',
})
const body = await res.json()
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body as { object: 'email'; id: string }
}
/** UTC calendar day for the run window — stable across retries the same day */
export function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
Wire it into the cron handler:
// app/api/cron/daily-digest/route.ts (continued)
import { sendTransactional, utcDayKey } from '@/lib/rovela'
import { buildDailyDigest } from '@/lib/digest' // your queries + HTML
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET
const auth = request.headers.get('authorization')
if (!secret || auth !== `Bearer ${secret}`) {
return new Response('Unauthorized', { status: 401 })
}
const day = utcDayKey()
const digest = await buildDailyDigest(day) // { subject, html, text, stats }
const result = await sendTransactional({
to: process.env.DIGEST_TO!.split(',').map((s) => s.trim()),
subject: digest.subject,
html: digest.html,
text: digest.text,
// One logical send per product per UTC day — safe if Vercel or you retry
idempotencyKey: `daily-digest:${day}`,
tags: { kind: 'daily-digest', day },
})
return Response.json({ ok: true, day, emailId: result.id, stats: digest.stats })
}
Recommended keys:
| Key | Use when |
|---|---|
daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
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 use POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)). Cap concurrency so you stay under rate limits; on 429, back off and retry with the same key.
Example digest body (keep it transactional)
Digests that go to your team or a single customer about their account are transactional. Do not stuff marketing blasts into the cron path — keep campaigns on a marketing tool and verified list.
// lib/digest.ts (sketch)
export async function buildDailyDigest(day: string) {
const stats = await loadStatsForDay(day) // your DB
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 }
}
Send both html and text. Put the numbers in the subject or the first lines of text so notification previews are useful.
Optional: dashboard template instead of inline HTML
If the layout is stable, store a template in the Rovela dashboard and pass variables (mutually exclusive with inline html / text on POST /emails):
body: JSON.stringify({
from: process.env.MAIL_FROM,
to: process.env.DIGEST_TO,
template: {
id: process.env.ROVELA_DIGEST_TEMPLATE_ID,
variables: {
day,
signups: String(stats.signups),
paid: String(stats.paid),
incidents: String(stats.incidents),
},
},
})
Batch sends cannot use template — use single POST /emails per templated message (Send Email API).
Local test without waiting for the schedule
Cron only fires on production. Locally:
curl -i "http://localhost:3000/api/cron/daily-digest" \
-H "Authorization: Bearer $CRON_SECRET"
Expect 401 without the header, 200 with it, and one Rovela message id in the JSON. Hitting the production URL with the same header is fine for a manual catch-up send — the day-scoped Idempotency-Key prevents a second copy if the scheduled run already succeeded within 24 hours.
Failure modes worth handling
| Symptom | Likely cause |
|---|---|
| Cron “succeeds” but no mail | Route returned 200 before POST /emails, or send threw after you already responded — await the send, then respond |
401 from your route in Vercel logs | CRON_SECRET missing on Production, typo, or middleware redirect |
Rovela 400 naming the domain | from not on a verified domain (Quick Start) |
Rovela 401 | Bad / revoked ROVELA_API_KEY, or wrong key type for the call |
Rovela 429 | Rate limit — retry with backoff, same Idempotency-Key |
| Duplicate digests | Key too coarse (missing day) or regenerated randomly each attempt |
| Job never runs | Preview deploy, bad cron path, or redirect on trailing slash |
For production visibility, subscribe to Rovela webhooks (email.delivered, email.bounced, …) instead of polling GET /emails/:id from the cron (reading needs full_access or a session; a sending_access key can only send (Authentication)).
Deploy checklist
- Verify the sending domain in Rovela; confirm SPF / DKIM / DMARC / return-path as the dashboard shows.
- Create a
sending_accesskey; setROVELA_API_KEY,MAIL_FROM,DIGEST_TO,CRON_SECRETon Production. - Commit
vercel.jsonwith the cron path + UTC schedule; deploy to production. - Confirm the cron appears in the Vercel project Cron UI after the production build.
- Trigger once manually with the Bearer secret; confirm one message id and one inbox delivery.
- Let the schedule fire once; confirm logs show a single Rovela id (idempotent replay if you re-hit the URL).
Summary
Vercel Cron is a production GET on a timer. Your route checks CRON_SECRET, builds a fresh digest or report, and calls Rovela POST /emails with an Idempotency-Key scoped to the run window. Secrets stay in project env. That is the overseas SaaS pattern for daily ops digests and customer activity reports — without standing up a separate mail worker, and without freezing stale HTML in scheduled_at when the numbers must be live.
Next: wire failure alerts the same way (separate path + key like alert-incident:{id}), or fan out per-tenant digests with batch + per-recipient keys.