← Back to blog

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.

Updated November 5, 2026
Azure Functions Timer Trigger Transactional Email with Rovela (2026): NCRONTAB + Key Vault + Idempotency-Key per Window

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:

  1. Reads ROVELA_API_KEY, MAIL_FROM, and recipients from App Settings / Key Vault (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 ScheduleStatus)
  4. 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:

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

ConcernOwner
NCRONTAB / TimeSpan schedule (%TIMER_SCHEDULE%)Timer Trigger (bindings)
WEBSITE_TIME_ZONE / wall-clock interpretationFunction App settings (time zones)
Singleton across scale-out (storage lock)Functions host + AzureWebJobsStorage
App Settings or Key Vault refs for ROVELA_API_KEYFunction App + Key Vault
Query DB / APIs for the windowYour function
Render HTML + textYour function
POST /emails, DKIM, queue, bouncesRovela
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: a tzutil.exe /L name such as Eastern Standard Time; Linux Premium/Dedicated: a tz database name such as America/New_York. WEBSITE_TIME_ZONE / TZ are not currently supported on Linux Flex Consumption or Consumption — those plans stay on UTC for the cron (NCRONTAB time zones).
  • IsPastDue / past_due. true when 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 never true in production — it fires on cold start, restart, and scale-out, which can spam digests and inflate Consumption cost (Caution).
  • useMonitor. Defaults to true for intervals ≥ 1 minute (persists occurrences so restarts do not lose the schedule); defaults to false for 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 /emails on 443 (Email API vs SMTP).
  • Secrets stay server-side. Prefer a Rovela sending_access key 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

  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 function (Authentication).
  3. A Function App (Node.js v4 model, Python, .NET isolated, etc.) with outbound HTTPS and enough timeout for your queries + one Rovela round-trip.
  4. Secrets: App Settings and/or Key Vault references for ROVELA_API_KEY, plus MAIL_FROM, DIGEST_TO (or look recipients up in code). Managed identity needs Key Vault Secrets User (RBAC) or Get on secrets (access policy).
  5. AzureWebJobsStorage configured (timer coordination).
  6. An NCRONTAB — for example 0 30 9 * * 1-5 with WEBSITE_TIME_ZONE where 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)

  1. Enable a system-assigned (or user-assigned) managed identity on the Function App.
  2. Grant that identity Key Vault Secrets User (RBAC) or Get on secrets (vault access policy).
  3. 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:

KeyUse 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

NCRONTABTimeSpan
Format0 30 9 * * 1-5 (seconds-first six-field, or five-field)01:00:00 / 1.00:00:00
MeaningWall-clock scheduleInterval between invocations
PlansAll plans that support Timer TriggerApp Service Plan only
Time zoneUTC by default; WEBSITE_TIME_ZONE where supportedInterval, 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

SymptomWhat to check
Rovela 400 naming a domainMAIL_FROM domain not verified in Rovela
Rovela 401Missing / wrong ROVELA_API_KEY; unresolved @Microsoft.KeyVault(...) literal
Rovela 429Back off; retry with the same Idempotency-Key
Duplicate mail after late run / manual invokeKey must be stable per window (or ScheduleStatus), not uuid() per invoke
Timer never fires on Linux ConsumptionExpected: 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 retriesTimer 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-outEnsure runOnStartup / run_on_startup is false
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 App Settings or Key Vault reference, not in Git
  2. NCRONTAB in %TIMER_SCHEDULE%; timezone chosen knowingly (UTC on Linux Consumption / Flex)
  3. runOnStartup=false; AzureWebJobsStorage healthy; unique host id if sharing storage
  4. Live aggregation at fire time; HTML + text both set when possible
  5. Idempotency-Key scoped to the schedule window (azure-… or ScheduleStatus stamp)
  6. Understand timer no auto-retry vs any explicit retry attribute you enable
  7. Application Insights for function health; Rovela dashboard or webhooks for delivery

Related reading