← Back to blog

Railway Cron Transactional Email with Rovela (2026): Secrets + Idempotency-Key per Window

Run a Railway cron service that exits cleanly, load ROVELA_API_KEY from Railway variables, and POST /emails with Idempotency-Key scoped to the UTC schedule window. Distinct from Vercel Cron routes and GitHub Actions run_id notifies.

Updated November 4, 2026
Railway Cron Transactional Email with Rovela (2026): Secrets + Idempotency-Key per Window

Railway cron runs the job — Rovela delivers the mail

Railway Cron Jobs start a service on a UTC crontab, run its start command, and expect that process to finish and exit. That is a natural place for 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 cron process calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Railway owns the schedule, the one-shot deployment, and your shared variables / secrets. Your code owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires a short-lived Railway cron service that:

  1. Reads ROVELA_API_KEY, MAIL_FROM, and recipient env from Railway (never hardcoded)
  2. Aggregates a digest / report for the current UTC window
  3. Sends via Rovela with Idempotency-Key keyed to that window
  4. Closes connections and exits so the next scheduled run is not skipped

Rovela does not create Railway cron schedules, set Railway variables, or keep your process alive. Railway owns start → exit. Your start command owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

What Railway cron does vs what Rovela sends

ConcernOwner
Five-field crontab (UTC), min 5 minutes apartRailway (Cron Jobs)
Start command → one deployment per fireRailway
Shared variables / secrets (ROVELA_API_KEY, …)Railway + your service config
Query DB / APIs for the windowYour cron process
Render HTML + textYour cron process
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = railway-{kind}-{yyyy-mm-dd} (or hour / week)Your start command
Process must exit; overlapping run is skippedRailway + your code

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

Railway constraints that shape the email job

From Railway’s docs, treat these as hard requirements for a mail-sending cron:

  • Exit when done. Close DB pools, HTTP clients, and anything else that keeps the event loop alive. If a previous execution is still Active, Railway skips the next fire (Cron Jobs).
  • Schedules are UTC. Convert local “9am Monday” yourself when you write the expression.
  • Minimum spacing is five minutes between runs; invalid patterns that leave shorter gaps are rejected.
  • Firing can drift by a few minutes under load — do not assume second-level precision. Key idempotency to a calendar window, not to “exact cron second.”
  • Inspect or trigger outside the schedule with railway cron list / railway cron run (CLI cron).

Use Railway cron for short tasks (daily digest, hourly ops snapshot). Use an always-on worker or a queue when work must not be skipped on overlap, or when you need sub-five-minute cadence (Cron vs workers vs queues).

When to use Railway cron + POST /emails (not scheduled_at)

Use Railway cron → live send when:

  • The HTML must include fresh metrics at fire time
  • You already host DB / workers on Railway and want the schedule next to that data
  • One (or a few) transactional messages go to fixed 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). Cron and scheduled_at solve different problems; this post is the Railway cron 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 cron service (Authentication).
  3. A Railway project with a service dedicated to the cron (or a service whose start command is only the cron task).
  4. Variables on that service / environment: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO (or look recipients up in code), plus DB / API credentials.
  5. A crontab in Settings → Cron Schedule (UTC), at least five minutes between fires.

Environment on Railway

In the Railway dashboard (service → Variables) or via the CLI, set:

ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com'
# plus DATABASE_URL / whatever your digest queries need

Mark the API key as a secret / sensitive variable so it is not echoed in logs. Never commit it. Rotate Rovela keys by create → update the Railway variable → redeploy / next cron run → revoke the old key (Authentication).

Configure the cron schedule

  1. Create or select the service that will run the mail job.
  2. Set the start command to your one-shot entrypoint (examples below).
  3. Open Settings → Cron Schedule and enter a five-field UTC expression (Cron Jobs).

Examples:

ExpressionMeaning (UTC)
0 13 * * *Every day at 13:00
0 */6 * * *Every six hours on the hour
*/15 * * * *Every 15 minutes (valid ≥ 5 minutes)

Do not leave a web server (node server.js, uvicorn --reload, …) as the start command for a cron service. The process must terminate.

Minimal Node cron entrypoint

package.json start (or Railway start command) should run a script that exits:

{
  "name": "railway-digest-cron",
  "type": "module",
  "scripts": {
    "start": "node src/digest-cron.mjs"
  },
  "engines": { "node": ">=20" }
}
// src/digest-cron.mjs
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
}

async function buildDailyDigest(day) {
  // Replace with your queries against Railway Postgres / 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 main() {
  const day = utcDayKey()
  const digest = await buildDailyDigest(day)
  const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())

  // One logical send per product per UTC day — safe if Railway or you re-trigger
  const result = await sendTransactional({
    to,
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    idempotencyKey: `railway-daily-digest:${day}`,
    tags: { kind: 'railway-daily-digest', day, platform: 'railway' },
  })

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

main()
  .then(() => process.exit(0))
  .catch((err) => {
    console.error(err)
    process.exit(1)
  })

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 Railway because you can re-run with railway cron run, and because a flaky network may retry your own fetch.

Recommended keys:

KeyUse when
railway-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
railway-hourly-ops:{yyyy-mm-dd}T{HH}Hourly snapshot (align with 0 * * * *)
railway-weekly-report:{yyyy}-W{ww}One founder report per ISO week
railway-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 (exit cleanly)

# digest_cron.py
import json
import os
import sys
from datetime import datetime, timezone

import urllib.request

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


def require_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        raise SystemExit(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 main() -> None:
    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"railway-daily-digest:{day}",
        tags={"kind": "railway-daily-digest", "day": day, "platform": "railway"},
    )
    print(json.dumps({"ok": True, "day": day, "emailId": result["id"], "stats": digest["stats"]}))


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(e, file=sys.stderr)
        sys.exit(1)

Railway start command example: python digest_cron.py. Ensure the process does not leave open Postgres connections — close pools / use context managers before returning.

Keep the body transactional

Digests to your team or a single customer about their account are transactional. Do not stuff marketing blasts into the cron path — keep campaigns on a marketing tool and verified list. Send both html and text; put the numbers in the subject or the first lines of text so notification previews are useful.

Optional: dashboard template

If the layout is stable, store a template in the Rovela dashboard and pass variables (mutually exclusive with inline html / text on POST /emails):

body: JSON.stringify({
  from: process.env.MAIL_FROM,
  to: process.env.DIGEST_TO,
  template: {
    id: process.env.ROVELA_DIGEST_TEMPLATE_ID,
    variables: {
      day,
      signups: String(stats.signups),
      paid: String(stats.paid),
      incidents: String(stats.incidents),
    },
  },
})

Batch sends cannot use template — use single POST /emails per templated message (Send Email API).

Test without waiting for the schedule

After variables and the start command are set:

railway cron run -y
railway cron list

Expect one successful run, one Rovela message id in the logs, and one inbox delivery. Re-running the same UTC day with the window-scoped Idempotency-Key returns the cached send instead of a second copy (within 24 hours).

Failure modes worth handling

SymptomLikely cause
Cron “succeeds” but no mailScript logged success before POST /emails, or swallowed the error — await the send, non-zero exit on failure
Next schedule skippedPrevious deployment still Active — leaked DB/HTTP handle, missing process.exit, hanging promise
Rovela 400 naming the domainfrom not on a verified domain (Quick Start)
Rovela 401Bad / revoked ROVELA_API_KEY, or wrong key type for the call
Rovela 429Rate limit — retry with backoff, same Idempotency-Key
Duplicate digestsKey too coarse (missing day/hour) or regenerated randomly each attempt
Job never runsNo cron schedule on the service, invalid expression (< 5 min gap), or wrong environment

For production visibility, subscribe to Rovela webhooks (email.delivered, email.bounced, …) instead of polling GET /emails/:id from the cron (reading needs full_access or a session; a sending_access key can only send (Authentication)).

Deploy checklist

  1. Verify the sending domain in Rovela; confirm SPF / DKIM / DMARC / return-path as the dashboard shows.
  2. Create a sending_access key; set ROVELA_API_KEY, MAIL_FROM, DIGEST_TO as Railway variables on the cron service.
  3. Set start command to a one-shot script that exits 0 on success and non-zero on send failure.
  4. Set Cron Schedule (UTC) with ≥ 5 minutes between runs; deploy.
  5. railway cron run once; confirm logs show a single Rovela id and the deployment reaches a terminal state (not stuck Active).
  6. Let the schedule fire once; confirm idempotent replay if you re-trigger the same window.

Summary

Put the schedule and secrets on Railway, the fresh aggregation in a process that exits, and the send on Rovela POST /emails with an Idempotency-Key scoped to the cron window. That split stays distinct from Vercel’s HTTP cron routes and from GitHub Actions’ run_id notifies — and it keeps transactional digests off SMTP sockets you would have to babysit inside a short-lived Railway deployment.