← Back to blog

AWS EventBridge Scheduler Transactional Email with Rovela (2026): Lambda cron + Idempotency-Key per Window

Run Amazon EventBridge Scheduler (cron/rate/at) to invoke Lambda, load ROVELA_API_KEY from env or Secrets Manager, and POST /emails with Idempotency-Key scoped to the schedule window. Distinct from Netlify Scheduled Functions, Fly Scheduled Machines, Render/Railway/Vercel cron, and GitHub Actions.

Updated November 5, 2026
AWS EventBridge Scheduler Transactional Email with Rovela (2026): Lambda cron + Idempotency-Key per Window

EventBridge Scheduler owns the cron — Rovela delivers the mail

Amazon EventBridge Scheduler invokes a target on a cron, rate, or one-time at(...) expression — including an AWS Lambda function. 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 Lambda calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). AWS owns the schedule, retries, and secret injection (Lambda environment variables or Secrets Manager). Your function owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires EventBridge Scheduler → Lambda → Rovela that:

  1. Reads ROVELA_API_KEY, MAIL_FROM, and recipients from Lambda env and/or Secrets Manager (never hardcoded)
  2. Aggregates a digest / report for the current schedule window
  3. Sends via Rovela with Idempotency-Key keyed to that window
  4. Survives Scheduler retries and Lambda async retries without duplicate mail

Rovela does not create EventBridge schedules, IAM roles, or Lambda functions. Scheduler owns cron → invoke. Your handler owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from older EventBridge Rules with a schedule expression (the classic “CloudWatch Events / EventBridge scheduled rule” path). Those rules still work for many accounts, but they evaluate schedule expressions in UTC only and lack Scheduler’s IANA time zones, flexible windows, and per-schedule retry / DLQ controls. Prefer EventBridge Scheduler for new email jobs; the Lambda → Rovela contract below is the same either way.

What Scheduler does vs what Rovela sends

ConcernOwner
cron(...) / rate(...) / at(...) expressionEventBridge Scheduler (schedule types)
IANA time zone (ScheduleExpressionTimezone)EventBridge Scheduler
Flexible time window / retry policy / DLQEventBridge Scheduler
IAM role that can lambda:InvokeFunctionYour schedule execution role
Env vars or Secrets Manager for ROVELA_API_KEYLambda + AWS secrets
Query DB / APIs for the windowYour Lambda
Render HTML + textYour Lambda
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = aws-{kind}-{yyyy-mm-dd} (or hour / week)Your handler

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

AWS constraints that shape the email job

From AWS docs, treat these as hard requirements for a mail-sending schedule:

  • Three expression kinds. Recurring: rate(value unit) with units minutes | hours | days, or six-field cron(minutes hours day-of-month month day-of-week year). One-shot: at(yyyy-mm-ddThh:mm:ss) (schedule types, CreateSchedule).
  • Day-of-month vs day-of-week. You cannot use * in both day fields; put ? in the one you are not constraining.
  • ~60-second precision. Scheduler invokes within the target minute when the flexible window is OFF (schedule types).
  • IANA time zones on Scheduler. Set ScheduleExpressionTimezone (for example Europe/London, America/New_York) so “09:00” stays local through daylight saving. Spring-forward skips a non-existent local time; fall-back invokes the repeated hour once (DST notes).
  • Flexible time window. FLEXIBLE + MaximumWindowInMinutes spreads invocations after the nominal time — useful for fleets, bad for “exactly 09:00 digest.” Use OFF when the window must be tight (flexible windows).
  • Async invoke + retries. Scheduler invokes Lambda asynchronously. Scheduler can retry failed target invocations (retry policy + optional DLQ, max age up to 24h, up to 185 retries). Lambda’s own async error path retries twice by default (Lambda retries, Scheduler getting started). Design for at-least-once delivery into your handler — that is why the Rovela Idempotency-Key is mandatory.
  • Outbound SMTP is a poor fit. Lambda (and many serverless hosts) block or frustrate port 25; prefer HTTPS POST /emails on 443 (Email API vs SMTP).
  • Secrets stay server-side. Put a Rovela sending_access key in a Lambda environment variable (console / SAM / Terraform) or in Secrets Manager; never ship it in a browser bundle (Authentication, Secrets Manager + Lambda).

When to use Scheduler + POST /emails (not scheduled_at)

Use EventBridge Scheduler → Lambda → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run compute on AWS and want IAM, CloudWatch Logs, and the schedule in the same account
  • You need a real crontab (weekday mornings in Europe/London, last Friday of the month, every 15 minutes) rather than coarse host buckets
  • 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 AWS 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 Lambda (Authentication).
  3. A Lambda function (Node.js, Python, or container image) with outbound HTTPS and enough timeout for your queries + one Rovela round-trip.
  4. Secrets: Lambda env vars and/or a Secrets Manager secret for ROVELA_API_KEY, plus MAIL_FROM, DIGEST_TO (or look recipients up in code).
  5. An IAM role Scheduler can assume with lambda:InvokeFunction on that function (Lambda + Scheduler).
  6. A schedule expression — for example cron(0 9 ? * MON-FRI *) with timezone Europe/London, or rate(1 hour).

Secrets on Lambda

Simplest path — encrypt-at-rest Lambda environment variables (set in the console, AWS CLI, SAM, or Terraform). Do not commit values to Git:

aws lambda update-function-configuration \
  --function-name daily-digest-email \
  --environment "Variables={ROVELA_API_KEY=re_your_sending_access_key,MAIL_FROM=Acme Reports <reports@mail.example.com>,DIGEST_TO=founders@example.com,DIGEST_KIND=daily}"

For rotation without redeploying code, store the Rovela key in Secrets Manager and either:

Keep sending_access on production senders — never full_access (Authentication). Never console.log the API key. Rotate by create → update secret / env → next successful invoke → revoke the old key.

Create the schedule

Console (fast path)

  1. Open the Lambda function → Add trigger → Scheduler (docs).
  2. Choose a recurring schedule, cron or rate, timezone, and optionally a JSON payload.
  3. Let Scheduler create the execution role, or attach one you already use.

AWS CLI (cron in local time)

aws scheduler create-schedule \
  --name daily-digest-london \
  --schedule-expression "cron(0 9 ? * MON-FRI *)" \
  --schedule-expression-timezone "Europe/London" \
  --flexible-time-window '{"Mode":"OFF"}' \
  --target '{
    "Arn": "arn:aws:lambda:eu-west-2:111122223333:function:daily-digest-email",
    "RoleArn": "arn:aws:iam::111122223333:role/scheduler-invoke-digest",
    "Input": "{\"kind\":\"daily\",\"market\":\"uk\"}"
  }'

Notes grounded in AWS docs:

  • Six-field cron: minutes, hours, day-of-month, month, day-of-week, year — here ? in day-of-month because day-of-week is MON-FRI (cron syntax).
  • --flexible-time-window '{"Mode":"OFF"}' keeps invocations near 09:00 local rather than smearing them across a window.
  • Configure a retry policy and optional DLQ in the Scheduler console or API if you want Scheduler-level retries separate from Lambda’s async retries (getting started).
  • Confirm fires in CloudWatch Logs for the function.

Rate example (every hour, UTC evaluation unless you only care about interval):

aws scheduler create-schedule \
  --name hourly-ops-snapshot \
  --schedule-expression "rate(1 hour)" \
  --flexible-time-window '{"Mode":"OFF"}' \
  --target '{
    "Arn": "arn:aws:lambda:eu-west-2:111122223333:function:daily-digest-email",
    "RoleArn": "arn:aws:iam::111122223333:role/scheduler-invoke-digest",
    "Input": "{\"kind\":\"hourly\"}"
  }'

Minimal Node.js Lambda handler

// index.mjs — Node.js 20+
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
}

async function buildDailyDigest(day) {
  // Replace with your queries against RDS / DynamoDB / APIs / CloudWatch Metrics
  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 }
}

export async function handler(event) {
  const kind = event?.kind || process.env.DIGEST_KIND || 'daily'
  const day = utcDayKey()
  const digest = await buildDailyDigest(day)
  const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())

  // Window key — NOT Date.now() / request id — so Scheduler + Lambda retries stay one logical mail
  const idempotencyKey =
    kind === 'hourly'
      ? `aws-hourly-ops:${utcHourKey()}`
      : `aws-daily-digest:${day}`

  const result = await sendTransactional({
    to,
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    idempotencyKey,
    tags: { kind: `aws-${kind}-digest`, day, platform: 'aws-eventbridge-scheduler' },
  })

  console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
  return { ok: true, emailId: result.id }
}

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 AWS because:

  • Scheduler may retry a failed invoke per its retry policy
  • Lambda async invocation retries failed invocations up to twice by default
  • A flexible window or a manual “Invoke” in the console may re-enter the same calendar window
  • Key the calendar window (and recipient, when fan-out), not the Lambda request id

Recommended keys:

KeyUse when
aws-daily-digest:{yyyy-mm-dd}One ops digest per UTC day (cron(0 9 …) daily)
aws-hourly-ops:{yyyy-mm-dd}T{HH}Hourly snapshot (rate(1 hour) or hourly cron)
aws-weekly-report:{yyyy}-W{ww}One founder report per ISO week
aws-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

# handler.py
import json
import os
import urllib.request
from datetime import datetime, timezone

ROVELA_URL = "https://api.rovela.dev/emails"


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 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())


def handler(event, context):
    day = utc_day_key()
    digest = 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["subject"],
        html=digest["html"],
        text=digest["text"],
        idempotency_key=f"aws-daily-digest:{day}",
        tags={"kind": "aws-daily-digest", "day": day, "platform": "aws-eventbridge-scheduler"},
    )
    print(json.dumps({"ok": True, "day": day, "emailId": result.get("id"), "stats": digest["stats"]}))
    return {"ok": True, "emailId": result.get("id")}

Scheduler vs classic EventBridge / CloudWatch scheduled rules

EventBridge SchedulerClassic scheduled rule
ServiceDedicated Scheduler APIEventBridge Rules (legacy CloudWatch Events schedule)
Expressionscron / rate / atcron / rate
Time zonesIANA via ScheduleExpressionTimezoneUTC evaluation
Flexible windowYesNo
Per-schedule retry / DLQYesLimited / different model
One-time at(...)YesUse one-shot patterns elsewhere

For new transactional digests on AWS, start with Scheduler. If you already have a Rule cron(0 13 * * ? *) targeting Lambda, keep it — still call Rovela with a window-scoped 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 env or Secrets Manager
Rovela 429Back off; retry with the same Idempotency-Key
Duplicate mail after retryKey must be stable per window (aws-daily-digest:{day}), not uuid() per invoke
Schedule fires late across many accountsFlexible window may be ON — set Mode: OFF for tight digests
Spring DST “missing” 09:00Expected skip for non-existent local times — pick a safe hour or accept the skip
Lambda timeout before Rovela returnsRaise timeout; shrink queries; do not start a second send with a new key
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 Lambda env or Secrets Manager, not in Git
  2. EventBridge Scheduler expression + timezone chosen knowingly (OFF flexible window for sharp digests)
  3. IAM: Scheduler role can invoke Lambda; Lambda role can reach Secrets Manager if used
  4. Live aggregation at fire time; HTML + text both set when possible
  5. Idempotency-Key scoped to the schedule window (aws-…:{day}) — mandatory under at-least-once retries
  6. CloudWatch Logs for function health; Rovela dashboard or webhooks for delivery

Related reading