GCP Cloud Scheduler Transactional Email with Rovela (2026): Cloud Functions/Run cron + Idempotency-Key per Window
Wire Google Cloud Scheduler (unix-cron/groc, HTTP or Pub/Sub, OIDC) to Cloud Functions or Cloud Run, keep ROVELA_API_KEY in Secret Manager, and POST /emails with a window-scoped Idempotency-Key—including X-CloudScheduler-ScheduleTime—for digests and ops reports.
Cloud Scheduler owns the cron — Rovela delivers the mail
Google Cloud Scheduler is a managed unix-cron (or groc) job runner. A job can push an HTTP request, publish to Pub/Sub, or hit an App Engine handler. That shape fits digests, ops reports, and other transactional mail whose body depends on live state at fire time — counts, failed jobs, unpaid invoices — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your Cloud Functions or Cloud Run handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). GCP owns the schedule, retries, and secret injection (env vars or Secret Manager). Your function owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Cloud Scheduler → Cloud Functions / Cloud Run → Rovela that:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipients from env and/or Secret Manager (never hardcoded) - Aggregates a digest / report for the current schedule window
- Sends via Rovela with
Idempotency-Keykeyed to that window (optionally derived fromX-CloudScheduler-ScheduleTime) - Survives Scheduler retries without duplicate mail
Rovela does not create Cloud Scheduler jobs, IAM bindings, or Cloud Run services. Scheduler owns cron → invoke. Your handler owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- AWS EventBridge Scheduler Email — six-field AWS cron /
rate/at+ Lambda, not GCP five-field unix-cron - Netlify Scheduled Functions Email — Netlify UTC cron + 30s function limit
- Fly Scheduled Machines Email — coarse
hourly/daily/weekly/monthlyMachine intervals - Render Cron Transactional Email — Cron Job process that must exit
- Railway Cron Transactional Email — Railway cron service; overlapping fires are skipped
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
What Scheduler does vs what Rovela sends
| Concern | Owner |
|---|---|
unix-cron / groc schedule + timeZone | Cloud Scheduler (cron format) |
| HTTP / Pub/Sub / App Engine target | Cloud Scheduler (Job resource) |
| OIDC token to invoke Cloud Run / Functions | Scheduler job (oidcToken) + invoker IAM |
Env vars or Secret Manager for ROVELA_API_KEY | Cloud Run / Functions + Secret Manager |
| Query DB / APIs for the window | Your handler |
| Render HTML + text | Your handler |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = gcp-{kind}-{yyyy-mm-dd} (or hour / week) | Your handler |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
GCP constraints that shape the email job
From Google Cloud docs, treat these as hard requirements for a mail-sending schedule:
- Five-field unix-cron (minute hour day-of-month month day-of-week), or human-readable groc (
every day 09:00,1st friday of quarter 9:00) viagcloud/ the REST API (cron format). - IANA
timeZone. Default is UTC. Schedules use wall-clock time; DST can skip or double a local hour — Google recommends UTC when cadence must be exact (time zone notes). - One outstanding execution. Execution n+1 does not start until n finishes; a late handler delays the next start (Job resource).
- Targets.
httpTarget,pubsubTarget, orappEngineHttpTarget. For Cloud Run / HTTPS Functions, use an HTTP job with OIDC (not OAuth — OAuth is for*.googleapis.comAPIs) (HTTP auth, Cloud Run on a schedule). - HTTP acknowledgement. Success is an HTTP 2xx. Missed deadlines and non-2xx count as failed attempts; Scheduler retries per
retryConfig(HttpTarget). attemptDeadline. For HTTP targets the default is 3 minutes; allowed range 15 seconds–30 minutes. Pub/Sub ignores this field (Job).retryConfig.retryCount.0(default) means no retry until the next scheduled fire; non-zero values (max 5) retry with exponential backoff (minBackoffDurationdefault 5s,maxBackoffDurationdefault 1h,maxDoublingsdefault 5) (RetryConfig).- Stable Scheduler headers on HTTP/App Engine targets. Cloud Scheduler sets
X-CloudScheduler: true,X-CloudScheduler-JobName, and — for unix-cron jobs —X-CloudScheduler-ScheduleTime(RFC3339 schedule instant). That schedule-time header stays constant across retries and is intended for request deduplication (HttpTarget headers). - Require authentication on Cloud Run. Deploy with authentication required; grant the Scheduler service account
roles/run.invoker(using Scheduler). - Outbound SMTP is a poor fit. Prefer HTTPS
POST /emailson 443 (Email API vs SMTP). - Secrets stay server-side. Mount a Rovela
sending_accesskey from Secret Manager (or a pinned env secret) — never ship it in a browser bundle (Authentication, Cloud Run secrets).
When to use Scheduler + POST /emails (not scheduled_at)
Use Cloud Scheduler → Functions / Run → live send when:
- The HTML must include fresh metrics at fire time
- You already run compute on GCP and want IAM, Cloud Logging, and the schedule in the same project
- You need a real crontab (weekday mornings in
Europe/London, every 15 minutes) or a groc phrase - 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). Scheduler and scheduled_at solve different problems; this post is the GCP path.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the handler (Authentication). - A Cloud Functions (2nd gen) or Cloud Run service with outbound HTTPS and enough timeout for your queries + one Rovela round-trip (keep Scheduler
attemptDeadline≥ that timeout). - Secrets: Secret Manager secret for
ROVELA_API_KEY, plusMAIL_FROM,DIGEST_TO(or look recipients up in code). Grant the runtime service accountroles/secretmanager.secretAccessor. - A dedicated Scheduler client service account with
roles/run.invoker(Cloud Run) or the equivalent invoker role on your HTTPS Function (using Scheduler). - A schedule — for example
0 9 * * MON-FRIwith--time-zone=Europe/London, orevery 1 hours.
Secrets on Cloud Run / Functions
Store the Rovela key in Secret Manager, then expose it as an environment variable (pin a version for env injection) or a mounted volume:
# Create the secret (value from stdin — do not commit it)
printf '%s' 're_your_sending_access_key' | \
gcloud secrets create rovela-api-key --data-file=-
# Cloud Run: map secret → env (runtime SA needs secretAccessor)
gcloud run services update daily-digest-email \
--region=europe-west1 \
--update-secrets=ROVELA_API_KEY=rovela-api-key:1 \
--update-env-vars=MAIL_FROM='Acme Reports <reports@mail.example.com>',DIGEST_TO=founders@example.com,DIGEST_KIND=daily
Keep sending_access on production senders — never full_access (Authentication). Never log the API key. Rotate by adding a new secret version → update the service → confirm a successful fire → disable the old version.
Create the Scheduler job (HTTP → Cloud Run)
Console (fast path)
- Deploy Cloud Run with Require authentication (docs).
- Create a service account for Scheduler; grant it
roles/run.invokeron that service. - Cloud Scheduler → Create job → frequency + timezone → Target HTTP → service URL → method
POST→ Auth header OIDC token → that service account → Audience = service URL (no query params).
gcloud (cron in local time)
PROJECT=my-gcp-project
REGION=europe-west1
SERVICE_URL=$(gcloud run services describe daily-digest-email \
--region="$REGION" --format='value(status.url)')
SA_EMAIL=scheduler-digest@$PROJECT.iam.gserviceaccount.com
gcloud run services add-iam-policy-binding daily-digest-email \
--region="$REGION" \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/run.invoker"
gcloud scheduler jobs create http daily-digest-london \
--location="$REGION" \
--schedule="0 9 * * MON-FRI" \
--time-zone="Europe/London" \
--uri="$SERVICE_URL/digest" \
--http-method=POST \
--headers="Content-Type=application/json" \
--message-body='{"kind":"daily","market":"uk"}' \
--oidc-service-account-email="$SA_EMAIL" \
--oidc-token-audience="$SERVICE_URL" \
--attempt-deadline=180s \
--max-retry-attempts=3 \
--min-backoff=30s \
--max-backoff=3600s
Notes grounded in GCP docs:
- Five-field cron: here weekday mornings at 09:00 London wall time (cron format).
- OIDC audience should be the service URL without query parameters when the job URI carries them (HTTP auth).
--attempt-deadline=180smatches the HTTP default of three minutes; raise it if your aggregation is slow (max 30 minutes).--max-retry-attemptsmaps toretryConfig.retryCount(0–5). With retries enabled, design for at-least-once delivery into your handler — that is why the RovelaIdempotency-Keyis mandatory.- Force a test fire:
gcloud scheduler jobs run daily-digest-london --location="$REGION".
Pub/Sub target (optional fan-out)
If several workers should react to the same tick, point Scheduler at a topic and subscribe a Function / Run service:
gcloud scheduler jobs create pubsub hourly-ops-tick \
--location="$REGION" \
--schedule="0 * * * *" \
--time-zone="UTC" \
--topic=cron-ops \
--message-body='{"kind":"hourly"}'
Pub/Sub delivery is still at-least-once. Keep the same window-scoped Rovela key in the subscriber. Scheduler publishes as a Google APIs service account; Pub/Sub attemptDeadline on the Job is ignored (Job).
Minimal Node.js Cloud Run / Functions handler
// index.mjs — Cloud Run (or Functions gen2 HTTP) Node.js 20+
import http from 'node:http'
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name) {
const v = process.env[name]
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}
/** Prefer Scheduler's stable schedule instant when present (constant across retries). */
function windowKeyFromHeaders(headers, kind) {
const scheduleTime = headers['x-cloudscheduler-scheduletime']
if (scheduleTime) {
// RFC3339 → compact stamp safe for Idempotency-Key (≤256 chars)
const stamp = String(scheduleTime).replace(/[:.]/g, '-').replace(/Z$/i, 'Z')
return `gcp-${kind}:${stamp}`
}
return kind === 'hourly'
? `gcp-hourly-ops:${utcHourKey()}`
: `gcp-daily-digest:${utcDayKey()}`
}
async function buildDailyDigest(day) {
// Replace with your queries against Cloud SQL / Firestore / BigQuery / APIs
const 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({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body // { object: 'email', id }
}
async function handleDigest(req, res) {
// Optional: reject non-Scheduler traffic in addition to Cloud Run IAM
if (req.headers['x-cloudscheduler'] !== 'true' && process.env.REQUIRE_SCHEDULER_HEADER === '1') {
res.writeHead(403).end('scheduler only')
return
}
let payload = {}
try {
const chunks = []
for await (const c of req) chunks.push(c)
if (chunks.length) payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
payload = {}
}
const kind = payload.kind || process.env.DIGEST_KIND || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
const idempotencyKey = windowKeyFromHeaders(req.headers, kind)
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `gcp-${kind}-digest`,
day,
platform: 'gcp-cloud-scheduler',
job: String(req.headers['x-cloudscheduler-jobname'] || ''),
},
})
console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, emailId: result.id }))
}
const server = http.createServer((req, res) => {
if (req.method === 'POST' && (req.url === '/' || req.url === '/digest')) {
handleDigest(req, res).catch((err) => {
console.error(err)
// Non-2xx → Scheduler counts a failed attempt and may retry
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'handler_failed' }))
})
return
}
res.writeHead(404).end()
})
server.listen(Number(process.env.PORT || 8080))
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 GCP because:
- Scheduler may retry a failed HTTP attempt up to
retryCounttimes with backoff - A manual
gcloud scheduler jobs runin the same calendar window can re-enter your handler X-CloudScheduler-ScheduleTimeis stable across retries — prefer it (or a calendar window) overuuid()/ request ids
Recommended keys:
| Key | Use when |
|---|---|
gcp-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day (0 9 * * * daily) |
gcp-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
gcp-{kind}:{X-CloudScheduler-ScheduleTime} | Strongest match to Scheduler’s own dedupe header |
gcp-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
gcp-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.
Python alternative (Functions / Run)
# main.py
import json
import os
import urllib.request
from datetime import datetime, timezone
from flask import Flask, request, jsonify
ROVELA_URL = "https://api.rovela.dev/emails"
app = Flask(__name__)
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise RuntimeError(f"missing env {name}")
return v
def utc_day_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def window_key(kind: str) -> str:
schedule_time = request.headers.get("X-CloudScheduler-ScheduleTime")
if schedule_time:
stamp = schedule_time.replace(":", "-").replace(".", "-")
return f"gcp-{kind}:{stamp}"
return f"gcp-daily-digest:{utc_day_key()}"
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = "\n".join(
[
f"Signups: {stats['signups']}",
f"Paid: {stats['paid']}",
f"Open incidents: {stats['incidents']}",
f"Failed jobs: {stats['failedJobs']}",
]
)
html = f"""
<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>
"""
return {"subject": subject, "html": html, "text": text, "stats": stats}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
@app.post("/")
@app.post("/digest")
def digest():
body = request.get_json(silent=True) or {}
kind = body.get("kind") or os.environ.get("DIGEST_KIND") or "daily"
day = utc_day_key()
digest_body = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
result = send_transactional(
to=to,
subject=digest_body["subject"],
html=digest_body["html"],
text=digest_body["text"],
idempotency_key=window_key(kind),
tags={
"kind": f"gcp-{kind}-digest",
"day": day,
"platform": "gcp-cloud-scheduler",
"job": request.headers.get("X-CloudScheduler-JobName", ""),
},
)
return jsonify({"ok": True, "emailId": result.get("id")})
HTTP vs Pub/Sub for email jobs
| HTTP target | Pub/Sub target | |
|---|---|---|
| Typical sink | Cloud Run / HTTPS Function URL | Topic → push/pull subscriber |
| Auth to sink | OIDC (Run/Functions) or OAuth (*.googleapis.com) | Scheduler publishes as Google APIs SA |
| Ack model | HTTP 2xx within attemptDeadline | Pub/Sub ack; Job attemptDeadline ignored |
| Dedup hint | X-CloudScheduler-ScheduleTime header | Message attributes / your own window key |
| Best when | One handler, simple path | Fan-out, buffering, multiple consumers |
Both paths still need a window-scoped Rovela Idempotency-Key.
Failure handling
| Symptom | What to check |
|---|---|
Rovela 400 naming a domain | MAIL_FROM domain not verified in Rovela |
Rovela 401 | Missing / wrong ROVELA_API_KEY in Secret Manager / env |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Duplicate mail after retry | Key must be stable per window (or X-CloudScheduler-ScheduleTime), not uuid() per invoke |
| Scheduler marks DEADLINE_EXCEEDED | Raise attemptDeadline and/or Cloud Run timeout; shrink queries |
| 403 from Cloud Run | Scheduler SA missing roles/run.invoker; OIDC audience mismatch |
| DST “missing” or doubled local hour | Expected wall-clock behavior — prefer UTC for exact cadence |
| Need delivery status in code | Prefer Rovela webhooks; GET /emails/:id/events needs full_access, while sending_access can only send |
Log Rovela’s message field as-is on non-2xx responses (Quick Start). Prefer webhooks over polling for production delivery state (Send Email API, Authentication).
Checklist
- Verified Rovela sending domain;
sending_accesskey in Secret Manager / env, not in Git - Cloud Scheduler cron or groc +
timeZonechosen knowingly (UTC when cadence must be exact) - HTTP job with OIDC → Cloud Run / Functions (or Pub/Sub → subscriber); invoker IAM in place
attemptDeadlineand handler timeout sized for query + Rovela round-trip;retryCount≤ 5 understood as at-least-once- Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped to the schedule window (gcp-…or Scheduler schedule-time header)- Cloud Logging for handler health; Rovela dashboard or webhooks for delivery
Related reading
- AWS EventBridge Scheduler Email
- Netlify Scheduled Functions Email
- Fly Scheduled Machines Email
- Render Cron Transactional Email
- Railway Cron Transactional Email
- Vercel Cron Digest Email
- GitHub Actions Transactional Email
- Scheduled Email Sending
- Email API vs SMTP
- Send Email API
- Authentication
- Create Cloud Scheduler jobs
- Cron format and time zones
- HTTP target authentication
- Cloud Run on a schedule
- Cloud Run secrets
- Job REST resource