← Back to blog

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.

Updated November 5, 2026
GCP Cloud Scheduler Transactional Email with Rovela (2026): Cloud Functions/Run cron + Idempotency-Key per Window

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:

  1. Reads ROVELA_API_KEY, MAIL_FROM, and recipients from env and/or Secret Manager (never hardcoded)
  2. Aggregates a digest / report for the current schedule window
  3. Sends via Rovela with Idempotency-Key keyed to that window (optionally derived from X-CloudScheduler-ScheduleTime)
  4. 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:

What Scheduler does vs what Rovela sends

ConcernOwner
unix-cron / groc schedule + timeZoneCloud Scheduler (cron format)
HTTP / Pub/Sub / App Engine targetCloud Scheduler (Job resource)
OIDC token to invoke Cloud Run / FunctionsScheduler job (oidcToken) + invoker IAM
Env vars or Secret Manager for ROVELA_API_KEYCloud Run / Functions + Secret Manager
Query DB / APIs for the windowYour handler
Render HTML + textYour handler
POST /emails, DKIM, queue, bouncesRovela
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) via gcloud / 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, or appEngineHttpTarget. For Cloud Run / HTTPS Functions, use an HTTP job with OIDC (not OAuth — OAuth is for *.googleapis.com APIs) (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 (minBackoffDuration default 5s, maxBackoffDuration default 1h, maxDoublings default 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 /emails on 443 (Email API vs SMTP).
  • Secrets stay server-side. Mount a Rovela sending_access key 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

  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 handler (Authentication).
  3. 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).
  4. Secrets: Secret Manager secret for ROVELA_API_KEY, plus MAIL_FROM, DIGEST_TO (or look recipients up in code). Grant the runtime service account roles/secretmanager.secretAccessor.
  5. A dedicated Scheduler client service account with roles/run.invoker (Cloud Run) or the equivalent invoker role on your HTTPS Function (using Scheduler).
  6. A schedule — for example 0 9 * * MON-FRI with --time-zone=Europe/London, or every 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)

  1. Deploy Cloud Run with Require authentication (docs).
  2. Create a service account for Scheduler; grant it roles/run.invoker on that service.
  3. 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=180s matches the HTTP default of three minutes; raise it if your aggregation is slow (max 30 minutes).
  • --max-retry-attempts maps to retryConfig.retryCount (0–5). With retries enabled, design for at-least-once delivery into your handler — that is why the Rovela Idempotency-Key is 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 retryCount times with backoff
  • A manual gcloud scheduler jobs run in the same calendar window can re-enter your handler
  • X-CloudScheduler-ScheduleTime is stable across retries — prefer it (or a calendar window) over uuid() / request ids

Recommended keys:

KeyUse 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 targetPub/Sub target
Typical sinkCloud Run / HTTPS Function URLTopic → push/pull subscriber
Auth to sinkOIDC (Run/Functions) or OAuth (*.googleapis.com)Scheduler publishes as Google APIs SA
Ack modelHTTP 2xx within attemptDeadlinePub/Sub ack; Job attemptDeadline ignored
Dedup hintX-CloudScheduler-ScheduleTime headerMessage attributes / your own window key
Best whenOne handler, simple pathFan-out, buffering, multiple consumers

Both paths still need a window-scoped Rovela Idempotency-Key.

Failure handling

SymptomWhat to check
Rovela 400 naming a domainMAIL_FROM domain not verified in Rovela
Rovela 401Missing / wrong ROVELA_API_KEY in Secret Manager / env
Rovela 429Back off; retry with the same Idempotency-Key
Duplicate mail after retryKey must be stable per window (or X-CloudScheduler-ScheduleTime), not uuid() per invoke
Scheduler marks DEADLINE_EXCEEDEDRaise attemptDeadline and/or Cloud Run timeout; shrink queries
403 from Cloud RunScheduler SA missing roles/run.invoker; OIDC audience mismatch
DST “missing” or doubled local hourExpected wall-clock behavior — prefer UTC for exact cadence
Need delivery status in codePrefer 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

  1. Verified Rovela sending domain; sending_access key in Secret Manager / env, not in Git
  2. Cloud Scheduler cron or groc + timeZone chosen knowingly (UTC when cadence must be exact)
  3. HTTP job with OIDC → Cloud Run / Functions (or Pub/Sub → subscriber); invoker IAM in place
  4. attemptDeadline and handler timeout sized for query + Rovela round-trip; retryCount ≤ 5 understood as at-least-once
  5. Live aggregation at fire time; HTML + text both set when possible
  6. Idempotency-Key scoped to the schedule window (gcp-… or Scheduler schedule-time header)
  7. Cloud Logging for handler health; Rovela dashboard or webhooks for delivery

Related reading