← Back to blog

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.

Updated November 4, 2026
Vercel Cron Digest Email with Rovela (2026): CRON_SECRET + POST /emails + Idempotency-Key

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:

  1. Rejects callers that do not present Authorization: Bearer ${CRON_SECRET} (Managing Cron Jobs)
  2. Aggregates a digest / report from your own data for the current UTC window
  3. Sends one (or a few) transactional messages via Rovela with Idempotency-Key keyed to that window
  4. Keeps ROVELA_API_KEY, CRON_SECRET, and MAIL_FROM in 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:

What Vercel Cron does vs what Rovela sends

ConcernOwner
Cron expression + production-only GET to your pathVercel (Cron Jobs)
CRON_SECRET → Authorization: Bearer … on each invocationVercel + your route check
Querying Postgres / KV / Stripe for the windowYour app
Rendering HTML + text digest / reportYour app
POST /emails, DKIM, queue, bouncesRovela
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

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer something like reports.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for the cron function (Authentication).
  3. A Vercel project with a production deployment. Cron jobs run on production deployments, not preview (Getting started with cron jobs).
  4. Environment variables on the project (Production): CRON_SECRET, ROVELA_API_KEY, MAIL_FROM, plus whatever DB / API credentials the digest needs.
  5. 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:

KeyUse 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

SymptomLikely cause
Cron “succeeds” but no mailRoute returned 200 before POST /emails, or send threw after you already responded — await the send, then respond
401 from your route in Vercel logsCRON_SECRET missing on Production, typo, or middleware redirect
Rovela 400 naming the domainfrom not on a verified domain (Quick Start)
Rovela 401Bad / revoked ROVELA_API_KEY, or wrong key type for the call
Rovela 429Rate limit — retry with backoff, same Idempotency-Key
Duplicate digestsKey too coarse (missing day) or regenerated randomly each attempt
Job never runsPreview 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

  1. Verify the sending domain in Rovela; confirm SPF / DKIM / DMARC / return-path as the dashboard shows.
  2. Create a sending_access key; set ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, CRON_SECRET on Production.
  3. Commit vercel.json with the cron path + UTC schedule; deploy to production.
  4. Confirm the cron appears in the Vercel project Cron UI after the production build.
  5. Trigger once manually with the Bearer secret; confirm one message id and one inbox delivery.
  6. 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.