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.
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:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipient env from Railway (never hardcoded) - Aggregates a digest / report for the current UTC window
- Sends via Rovela with
Idempotency-Keykeyed to that window - 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:
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET, not a Railway service that exits - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events, not a platform crontab - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
What Railway cron does vs what Rovela sends
| Concern | Owner |
|---|---|
| Five-field crontab (UTC), min 5 minutes apart | Railway (Cron Jobs) |
| Start command → one deployment per fire | Railway |
Shared variables / secrets (ROVELA_API_KEY, …) | Railway + your service config |
| Query DB / APIs for the window | Your cron process |
| Render HTML + text | Your cron process |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = railway-{kind}-{yyyy-mm-dd} (or hour / week) | Your start command |
| Process must exit; overlapping run is skipped | Railway + 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
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the cron service (Authentication). - A Railway project with a service dedicated to the cron (or a service whose start command is only the cron task).
- Variables on that service / environment:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(or look recipients up in code), plus DB / API credentials. - 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
- Create or select the service that will run the mail job.
- Set the start command to your one-shot entrypoint (examples below).
- Open Settings → Cron Schedule and enter a five-field UTC expression (Cron Jobs).
Examples:
| Expression | Meaning (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:
| Key | Use 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
| Symptom | Likely cause |
|---|---|
| Cron “succeeds” but no mail | Script logged success before POST /emails, or swallowed the error — await the send, non-zero exit on failure |
| Next schedule skipped | Previous deployment still Active — leaked DB/HTTP handle, missing process.exit, hanging promise |
Rovela 400 naming the domain | from not on a verified domain (Quick Start) |
Rovela 401 | Bad / revoked ROVELA_API_KEY, or wrong key type for the call |
Rovela 429 | Rate limit — retry with backoff, same Idempotency-Key |
| Duplicate digests | Key too coarse (missing day/hour) or regenerated randomly each attempt |
| Job never runs | No 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
- Verify the sending domain in Rovela; confirm SPF / DKIM / DMARC / return-path as the dashboard shows.
- Create a
sending_accesskey; setROVELA_API_KEY,MAIL_FROM,DIGEST_TOas Railway variables on the cron service. - Set start command to a one-shot script that exits
0on success and non-zero on send failure. - Set Cron Schedule (UTC) with ≥ 5 minutes between runs; deploy.
railway cron runonce; confirm logs show a single Rovela id and the deployment reaches a terminal state (not stuckActive).- 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.