Azure Functions Timer Trigger Transactional Email with Rovela (2026): NCRONTAB + Key Vault + Idempotency-Key per Window
Wire an Azure Functions Timer Trigger (NCRONTAB, App Settings / Key Vault references, WEBSITE_TIME_ZONE where supported) to POST /emails with a window-scoped Idempotency-Key for digests and ops reports—distinct from GCP Scheduler, EventBridge, and Netlify/Fly/Render cron.
The Timer Trigger owns the NCRONTAB — Rovela delivers the mail
Azure Functions Timer Trigger runs your function on an NCRONTAB (or App Service Plan TimeSpan) schedule inside the Functions host. 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 the function calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Azure owns the schedule, scale-out singleton behavior, and secret injection (App Settings or Key Vault references). Your function owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Azure Functions Timer Trigger → Rovela that:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipients from App Settings / Key Vault (never hardcoded) - Aggregates a digest / report for the current schedule window
- Sends via Rovela with
Idempotency-Keykeyed to that window (optionally derived fromScheduleStatus) - Survives late (
IsPastDue) runs and manual admin invokes without duplicate mail
Rovela does not create Function Apps, timer bindings, storage accounts, or Key Vault access policies. Azure owns NCRONTAB → invoke. Your function owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- GCP Cloud Scheduler Email — five-field unix-cron / groc + HTTP/OIDC or Pub/Sub into Cloud Run / Functions
- AWS EventBridge Scheduler Email — six-field AWS cron /
rate/at+ Lambda (not Azure NCRONTAB) - 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
Also distinct from Azure Logic Apps Recurrence or Azure Automation runbooks: those are separate schedulers. This post is the in-process Timer Trigger binding on a Function App.
What the Timer Trigger does vs what Rovela sends
| Concern | Owner |
|---|---|
NCRONTAB / TimeSpan schedule (%TIMER_SCHEDULE%) | Timer Trigger (bindings) |
WEBSITE_TIME_ZONE / wall-clock interpretation | Function App settings (time zones) |
| Singleton across scale-out (storage lock) | Functions host + AzureWebJobsStorage |
App Settings or Key Vault refs for ROVELA_API_KEY | Function App + Key Vault |
| Query DB / APIs for the window | Your function |
| Render HTML + text | Your function |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = azure-{kind}-{yyyy-mm-dd} (or hour / week / ScheduleStatus) | Your function |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Azure constraints that shape the email job
From Microsoft Learn docs, treat these as hard requirements for a mail-sending timer:
- NCRONTAB six fields (seconds first).
{second} {minute} {hour} {day} {month} {day-of-week}via the NCronTab library. Five-field expressions are also accepted; the sixth (seconds) sits at the beginning when present. Example:0 30 9 * * 1-5→ 09:30 every weekday. - Schedule from App Settings. Put the expression in an app setting and reference it as
%TIMER_SCHEDULE%so you can change cadence without redeploying code (attributes / configuration). - Default timezone is UTC. For another zone, set
WEBSITE_TIME_ZONE— Windows: atzutil.exe /Lname such asEastern Standard Time; Linux Premium/Dedicated: a tz database name such asAmerica/New_York.WEBSITE_TIME_ZONE/TZare not currently supported on Linux Flex Consumption or Consumption — those plans stay on UTC for the cron (NCRONTAB time zones). IsPastDue/past_due.truewhen the invocation is later than scheduled (for example after a restart that missed a tick) (Usage). Still send if the digest should catch up — but keep the same window-scoped Rovela key so a late retry does not double-mail.- Singleton on scale-out. Only one instance of a timer-triggered function runs across scaled-out instances; it will not fire again while an outstanding invocation is still running (Scale-out).
- No automatic retry after failure. Unlike the queue trigger, a failed timer run is not retried until the next schedule occurrence (Retry behavior). Optional host/function retry attributes (for example C#
FixedDelayRetry) are separate — if you enable them, design for at-least-once and keep the Idempotency-Key. runOnStartup. Almost nevertruein production — it fires on cold start, restart, and scale-out, which can spam digests and inflate Consumption cost (Caution).useMonitor. Defaults totruefor intervals ≥ 1 minute (persists occurrences so restarts do not lose the schedule); defaults tofalsefor sub-minute schedules (Configuration).- Storage lock. Timer coordination uses blob storage via
AzureWebJobsStorage. Function apps that share storage and the same host id can starve each other’s timers — give each app a distinct host id when sharing storage (Function apps sharing Storage). - TimeSpan schedules (
hh:mm:ss) only on App Service Plan, not Consumption (TimeSpan). - Outbound SMTP is a poor fit. Prefer HTTPS
POST /emailson 443 (Email API vs SMTP). - Secrets stay server-side. Prefer a Rovela
sending_accesskey in App Settings or a Key Vault reference — never ship it in a browser bundle (Authentication, Key Vault references).
When to use Timer Trigger + POST /emails (not scheduled_at)
Use Timer Trigger → live send when:
- The HTML must include fresh metrics at fire time
- You already run compute on Azure Functions and want App Insights, App Settings, and the schedule in the same Function App
- You need a real NCRONTAB (weekday mornings, every 15 minutes) rather than freezing bodies hours ahead
- 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). The Timer Trigger and scheduled_at solve different problems; this post is the Azure path.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the function (Authentication). - A Function App (Node.js v4 model, Python, .NET isolated, etc.) with outbound HTTPS and enough timeout for your queries + one Rovela round-trip.
- Secrets: App Settings and/or Key Vault references for
ROVELA_API_KEY, plusMAIL_FROM,DIGEST_TO(or look recipients up in code). Managed identity needs Key Vault Secrets User (RBAC) or Get on secrets (access policy). AzureWebJobsStorageconfigured (timer coordination).- An NCRONTAB — for example
0 30 9 * * 1-5withWEBSITE_TIME_ZONEwhere your plan supports it, or keep UTC on Linux Consumption / Flex Consumption.
Secrets: App Settings and Key Vault references
App Settings (fast path)
Set in the portal, Azure CLI, Bicep, or Terraform. Locally, put values under Values in local.settings.json (never commit real keys):
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",
"TIMER_SCHEDULE": "0 30 9 * * 1-5",
"ROVELA_API_KEY": "re_your_sending_access_key",
"MAIL_FROM": "Acme Reports <reports@mail.example.com>",
"DIGEST_TO": "founders@example.com",
"DIGEST_KIND": "daily"
}
}
Production CLI sketch (do not echo secrets into shell history — prefer Key Vault):
az functionapp config appsettings set \
--name daily-digest-email \
--resource-group rg-mail \
--settings \
TIMER_SCHEDULE="0 30 9 * * 1-5" \
MAIL_FROM="Acme Reports <reports@mail.example.com>" \
DIGEST_TO="founders@example.com" \
DIGEST_KIND=daily
Key Vault reference (recommended for ROVELA_API_KEY)
- Enable a system-assigned (or user-assigned) managed identity on the Function App.
- Grant that identity Key Vault Secrets User (RBAC) or Get on secrets (vault access policy).
- Set the app setting value to a reference — your code still reads
process.env.ROVELA_API_KEY/os.environ["ROVELA_API_KEY"]as a normal string (Key Vault references):
@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/rovela-api-key)
or:
@Microsoft.KeyVault(VaultName=myvault;SecretName=rovela-api-key)
Unresolved references stay as the literal @Microsoft.KeyVault(...) string and will break auth — check the portal edit dialog / Key Vault Application Settings Diagnostics if sends return 401. The platform refreshes referenced secrets about every 24 hours; a configuration change forces an immediate refetch. Keep sending_access on production senders — never full_access (Authentication). Never log the API key.
Timer binding (Node.js v4)
// src/functions/dailyDigestEmail.js
const { app } = require('@azure/functions');
const { sendDigest } = require('../lib/sendDigest');
app.timer('dailyDigestEmail', {
// Pull schedule from App Setting TIMER_SCHEDULE (NCRONTAB)
schedule: '%TIMER_SCHEDULE%',
// Never true in production for mail jobs
runOnStartup: false,
handler: async (myTimer, context) => {
if (myTimer.isPastDue) {
context.log('Timer is past due — still sending with window-scoped Idempotency-Key');
}
const result = await sendDigest({
scheduleStatus: myTimer.scheduleStatus,
isPastDue: myTimer.isPastDue,
log: context.log.bind(context),
});
context.log(JSON.stringify({ ok: true, emailId: result.id }));
},
});
Equivalent Python v2 decorator shape (docs):
import azure.functions as func
app = func.FunctionApp()
@app.function_name(name="dailyDigestEmail")
@app.timer_trigger(
schedule="%TIMER_SCHEDULE%",
arg_name="mytimer",
run_on_startup=False,
)
def daily_digest_email(mytimer: func.TimerRequest) -> None:
# call the same sendDigest logic as Node
...
Minimal Node.js sender (window-scoped Idempotency-Key)
// src/lib/sendDigest.js
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 ScheduleStatus.Last / Next when present — stable across late / manual re-runs of the same occurrence. */
function windowKeyFromTimer(scheduleStatus, kind) {
const stampSrc =
(scheduleStatus && (scheduleStatus.last || scheduleStatus.Last)) ||
(scheduleStatus && (scheduleStatus.next || scheduleStatus.Next)) ||
null;
if (stampSrc) {
const stamp = String(stampSrc).replace(/[:.]/g, '-');
return `azure-${kind}:${stamp}`.slice(0, 256);
}
return kind === 'hourly'
? `azure-hourly-ops:${utcHourKey()}`
: `azure-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// Replace with your queries against Azure SQL / Cosmos DB / 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 sendDigest({ scheduleStatus, isPastDue, log }) {
const kind = process.env.DIGEST_KIND || 'daily';
const day = utcDayKey();
const digest = await buildDailyDigest(day);
const to = requireEnv('DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKeyFromTimer(scheduleStatus, kind);
log(
JSON.stringify({
kind,
day,
isPastDue: Boolean(isPastDue),
idempotencyKey,
}),
);
return sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `azure-${kind}-digest`,
day,
platform: 'azure-functions-timer',
past_due: String(Boolean(isPastDue)),
},
});
}
module.exports = { sendDigest, windowKeyFromTimer };
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 Azure because:
- A past-due run after restart can re-enter the same calendar window
- Admin / webhook manual invoke can fire the timer outside the cron for smoke tests
- Optional function-level retries (
FixedDelayRetry, host retry policies) create at-least-once delivery into your handler
Recommended keys:
| Key | Use when |
|---|---|
azure-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
azure-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 0 * * * *) |
azure-{kind}:{ScheduleStatus.Last} | Strongest match to the timer occurrence the host recorded |
azure-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
azure-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
# function_app.py (excerpt)
import json
import os
import urllib.request
from datetime import datetime, timezone
import azure.functions as func
ROVELA_URL = "https://api.rovela.dev/emails"
app = func.FunctionApp()
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(mytimer: func.TimerRequest, kind: str) -> str:
status = getattr(mytimer, "schedule_status", None) or {}
stamp_src = None
if isinstance(status, dict):
stamp_src = status.get("last") or status.get("Last") or status.get("next") or status.get("Next")
else:
stamp_src = getattr(status, "last", None) or getattr(status, "next", None)
if stamp_src:
stamp = str(stamp_src).replace(":", "-").replace(".", "-")
return f"azure-{kind}:{stamp}"[:256]
return f"azure-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.function_name(name="dailyDigestEmail")
@app.timer_trigger(schedule="%TIMER_SCHEDULE%", arg_name="mytimer", run_on_startup=False)
def daily_digest_email(mytimer: func.TimerRequest) -> None:
if mytimer.past_due:
# Still send; Idempotency-Key prevents duplicates for the window
pass
kind = 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()]
send_transactional(
to=to,
subject=digest_body["subject"],
html=digest_body["html"],
text=digest_body["text"],
idempotency_key=window_key(mytimer, kind),
tags={
"kind": f"azure-{kind}-digest",
"day": day,
"platform": "azure-functions-timer",
"past_due": str(bool(mytimer.past_due)),
},
)
NCRONTAB vs TimeSpan for email jobs
| NCRONTAB | TimeSpan | |
|---|---|---|
| Format | 0 30 9 * * 1-5 (seconds-first six-field, or five-field) | 01:00:00 / 1.00:00:00 |
| Meaning | Wall-clock schedule | Interval between invocations |
| Plans | All plans that support Timer Trigger | App Service Plan only |
| Time zone | UTC by default; WEBSITE_TIME_ZONE where supported | Interval, not wall clock |
| Best when | “09:30 weekdays” digests | “every hour after previous finish” on dedicated plans |
For founder/ops digests, prefer NCRONTAB so the fire time is predictable.
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; unresolved @Microsoft.KeyVault(...) literal |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Duplicate mail after late run / manual invoke | Key must be stable per window (or ScheduleStatus), not uuid() per invoke |
| Timer never fires on Linux Consumption | Expected: use UTC NCRONTAB — WEBSITE_TIME_ZONE unsupported on Linux Consumption / Flex Consumption |
| Two Function Apps, one timer “wins” | Distinct AzureFunctionsWebHost__hostid when sharing storage |
| Function fails and never retries | Timer does not auto-retry — fix the error and wait for the next schedule, or add an explicit retry policy + Idempotency-Key |
| Surprise sends on deploy / scale-out | Ensure runOnStartup / run_on_startup is false |
| 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 App Settings or Key Vault reference, not in Git - NCRONTAB in
%TIMER_SCHEDULE%; timezone chosen knowingly (UTC on Linux Consumption / Flex) runOnStartup=false;AzureWebJobsStoragehealthy; unique host id if sharing storage- Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped to the schedule window (azure-…orScheduleStatusstamp)- Understand timer no auto-retry vs any explicit retry attribute you enable
- Application Insights for function health; Rovela dashboard or webhooks for delivery
Related reading
- GCP Cloud Scheduler Email
- 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
- Timer trigger for Azure Functions
- Run scheduled tasks using Azure Functions
- Key Vault references as app settings
- App settings reference for Azure Functions
- Manually run a non-HTTP-triggered function