Upstash QStash Schedules Transactional Email with Rovela (2026): Signature Verify + Idempotency-Key per Window
Wire Upstash QStash cron schedules that HTTPS-invoke your endpoint to Rovela POST /emails: verify Upstash-Signature, keep sending_access server-only, and use a window-scoped Idempotency-Key so QStash retries never duplicate digests.
Upstash QStash Schedules own the cron — Rovela delivers the mail
Upstash QStash is a serverless HTTP message queue and scheduler. Beyond one-shot publishes, you can create a schedule so QStash republishes the same message on a cron cadence — by POSTing to https://qstash.upstash.io/v2/schedules/{destination} (or a regional qstash-{region}.upstash.io host) with an Upstash-Cron header. Each tick is an HTTPS invoke of your destination URL (or URL Group), with automatic retries when your endpoint errors.
That shape fits digests, ops reports, trial-expiry reminders, and other transactional messages whose body depends on live app or database state at fire time — signup counts, unpaid invoices, failed jobs — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your destination endpoint calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). QStash owns the schedule, HTTP delivery, and retries. Your app owns signature verification, aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires QStash Schedules → your HTTPS endpoint → Rovela that:
- Creates a schedule with
Upstash-Cron(UTC by default, orCRON_TZ=…for an IANA timezone) and an optional stableUpstash-Schedule-Id - Verifies every inbound request with the QStash
Upstash-SignatureJWT (Receiver/verifySignatureAppRouter) using current + next signing keys - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, and recipients in server-only secrets — never in the browser or a public Git tree - Aggregates a digest / report for the current schedule window
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives QStash retries, overlapping ticks, and manual re-publishes without duplicate mail
Rovela does not create QStash schedules, signing keys, or URL Groups. QStash owns schedule → HTTPS POST → retries. Your endpoint owns verify → query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Supabase pg_cron Email — Postgres
pg_cron+pg_net→ Edge Function (not QStash HTTP schedules) - Kubernetes CronJob Email —
batch/v1CronJob → Job Pod start→exit - DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob - Heroku Scheduler Email — coarse Scheduler dynos
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB + App Settings
- GCP Cloud Scheduler Email — Cloud Scheduler HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions Email — Netlify UTC cron
- Fly Scheduled Machines Email — Fly Machine schedules
- Render Cron Transactional Email — Render Cron Job process
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — platform cron
GETto a production route - GitHub Actions Transactional Email — workflow / CI notify
- Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
Also distinct from a one-shot QStash publish (delay once) or a long-lived queue consumer — this post is the recurring schedule path only.
What QStash Schedules do vs what Rovela sends
| Concern | Owner |
|---|---|
Cron expression (Upstash-Cron), optional CRON_TZ=IANA, schedule id | QStash Schedules (schedules, Create a Schedule) |
| HTTPS POST (default) to destination URL / URL Group; retries / timeout | QStash delivery |
Verify Upstash-Signature (JWT) with current + next signing keys | Your endpoint (Verify Signatures) |
Server secrets for ROVELA_API_KEY (sending_access) | Your host / vault / platform env |
| Query DB / APIs for the window | Your endpoint |
| Render HTML + text | Your endpoint |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = qstash-cron-daily-digest:{yyyy-mm-dd} (or hour / minute bucket) | Your endpoint |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
QStash schedule constraints that shape the email endpoint
From Upstash docs, treat these as hard requirements for a mail-sending schedule:
- Create with
Upstash-Cron. Schedules are created by POSTing to/v2/schedules/{destination}with the cron header (orclient.schedules.create({ destination, cron })in the TypeScript SDK). Destination must behttp://orhttps://(or a URL Group name) (schedules). - Max resolution is 1 minute. Finest tick is once per minute — not sub-minute (Create a Schedule).
- Default timezone is UTC. Prefix the cron with
CRON_TZ=America/New_York(any IANA zone) when you need local wall time (Timezones). - First fire can lag ~60 seconds after create while the schedule loads on an active node (schedules).
- Stable schedule ids. Pass
Upstash-Schedule-Id/scheduleIdto create-or-update the same schedule instead of spawning duplicates (Overwrite). Ids are alphanumeric / hyphen / period / underscore. - Verify every request. QStash destinations are public HTTPS URLs. Anyone who learns the path can POST unless you verify the
Upstash-SignatureJWT with your current and next signing keys from the QStash console (Verify Signatures, Next.js quickstart). - Use the raw body for verification. Frameworks that parse JSON before verify can break the body hash claim — feed the raw string to
Receiver.verify(signature howto). - Retries are expected. Default is 3 retries (total deliveries = 1 + retries) when your endpoint errors or times out. Tune with
Upstash-Retries/Upstash-Retry-Delay. Your Rovela send must be idempotent viaIdempotency-Key. - Keep the Rovela key off QStash. Do not put
ROVELA_API_KEYin the schedule body orUpstash-Forward-*headers that land in logs. Keepsending_accessonly in your app’s server env and call Rovela from the verified handler (Authentication). - Prefer HTTPS
POST /emails. Outbound SMTP from a serverless handler is a poor fit (Email API vs SMTP).
When to use QStash Schedules + POST /emails (not scheduled_at)
Use QStash schedule → live send when:
- The HTML must include fresh metrics at fire time
- You already use Upstash (Redis / QStash) and want a scheduler that is not tied to one PaaS cron product
- You need HTTP retries, pause/resume, and console-managed schedules without a long-lived worker
- 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). QStash schedules and scheduled_at solve different problems; this post is the QStash path.
Use a platform cron (Vercel / Render / K8s / Supabase pg_cron / …) when you prefer that host’s scheduler instead of an HTTP message bus — see the distinct posts linked above.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the schedule handler (Authentication). - An Upstash account with QStash enabled, a QStash token, and current + next signing keys from the console.
- A publicly reachable HTTPS endpoint (Next.js App Router, Hono on Cloudflare/Fly, etc.) that can verify signatures and call Rovela.
- Server env for
QSTASH_CURRENT_SIGNING_KEY,QSTASH_NEXT_SIGNING_KEY,ROVELA_API_KEY,MAIL_FROM,DIGEST_TO.
Create the schedule (cURL + TypeScript)
Replace the destination with your production URL. Prefer an explicit schedule id so redeploys update rather than duplicate:
curl -XPOST \
'https://qstash.upstash.io/v2/schedules/https://api.example.com/qstash/daily-digest' \
-H "Authorization: Bearer $QSTASH_TOKEN" \
-H "Content-Type: application/json" \
-H "Upstash-Cron: CRON_TZ=America/New_York 0 4 * * *" \
-H "Upstash-Schedule-Id: daily-digest" \
-H "Upstash-Retries: 3" \
-H "Upstash-Timeout: 30s" \
-d '{"kind":"daily"}'
TypeScript SDK equivalent (schedules examples):
import { Client } from "@upstash/qstash";
const client = new Client({ token: process.env.QSTASH_TOKEN! });
await client.schedules.create({
destination: "https://api.example.com/qstash/daily-digest",
cron: "CRON_TZ=America/New_York 0 4 * * *",
scheduleId: "daily-digest",
retries: 3,
body: JSON.stringify({ kind: "daily" }),
headers: { "content-type": "application/json" },
});
Every-15-minutes ops pulse (UTC):
await client.schedules.create({
destination: "https://api.example.com/qstash/ops-pulse",
cron: "*/15 * * * *",
scheduleId: "ops-pulse",
body: JSON.stringify({ kind: "fifteen" }),
headers: { "content-type": "application/json" },
});
Pause / resume / delete via the Schedules API or console when you need to stop mail without deleting code (OpenAPI schedules).
Next.js App Router: verify signature, then send with Rovela
Install @upstash/qstash. Set env from the QStash dashboard (QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY) plus Rovela secrets. The Next.js helper loads those signing keys automatically (Next.js quickstart):
// app/api/qstash/daily-digest/route.ts
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
const ROVELA_URL = "https://api.rovela.dev/emails";
function requireEnv(name: string) {
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
}
function windowKey(kind: string) {
if (kind === "hourly") return `qstash-cron-hourly-ops:${utcHourKey()}`;
if (kind === "fifteen") {
const d = new Date();
const bucket = Math.floor(d.getUTCMinutes() / 15) * 15;
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(bucket).padStart(2, "0");
return `qstash-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
}
return `qstash-cron-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day: string) {
// Replace with your queries against Postgres / Redis / 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(opts: {
to: string[];
subject: string;
html: string;
text: string;
idempotencyKey: string;
tags: Record<string, string>;
}) {
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${requireEnv("ROVELA_API_KEY")}`,
"Content-Type": "application/json",
"Idempotency-Key": opts.idempotencyKey,
},
body: JSON.stringify({
from: requireEnv("MAIL_FROM"),
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
tags: opts.tags,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${(body as any).message ?? "send failed"}`);
}
return body as { object: string; id: string };
}
async function handler(req: Request) {
const payload = (await req.json().catch(() => ({}))) as { kind?: string };
const kind = payload.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 = windowKey(kind);
// never log ROVELA_API_KEY or QStash signing keys
console.log(JSON.stringify({ kind, day, idempotencyKey }));
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `qstash-cron-${kind}-digest`,
day,
platform: "upstash-qstash",
},
});
return Response.json({ ok: true, emailId: result.id });
}
// Verifies Upstash-Signature before your handler runs
export const POST = verifySignatureAppRouter(handler);
Return non-2xx only when you want QStash to retry. If Rovela already accepted the message under the same Idempotency-Key, a later retry that still gets 200 from Rovela is safe — do not throw after a successful accept.
Manual verification with Receiver (any Node runtime)
When you are not on Next.js App Router, verify explicitly with the raw body (Receiver):
import { Receiver } from "@upstash/qstash";
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
export async function POST(req: Request) {
const signature = req.headers.get("upstash-signature");
if (!signature) {
return new Response("missing signature", { status: 401 });
}
const body = await req.text(); // raw string — required for verify
try {
await receiver.verify({
signature,
body,
url: "https://api.example.com/qstash/daily-digest", // destination you scheduled
});
} catch {
return new Response("invalid signature", { status: 401 });
}
const payload = JSON.parse(body || "{}") as { kind?: string };
// ... same build + Rovela POST /emails as above ...
return Response.json({ ok: true });
}
Python equivalent uses qstash.Receiver the same way (signature howto).
Window-scoped Idempotency-Key
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 with QStash because:
- Default retries re-POST your endpoint after 5xx / timeouts
- You may manually publish the same destination while debugging
- A slow handler + overlapping cron ticks can re-enter the same calendar window
- Schedule overwrite / accidental duplicate schedule ids (if you omit a stable id) can double-fire
Recommended keys:
| Key | Use when |
|---|---|
qstash-cron-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
qstash-cron-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
qstash-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | Every-15-minutes pulse (mm floored to 00/15/30/45) |
qstash-cron-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
qstash-cron-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.
Attach secrets and verify the schedule
- Deploy the verified endpoint; confirm
QSTASH_*signing keys andROVELA_API_KEY(sending_access) are set server-side only. - Create (or overwrite) the schedule with a stable
scheduleId. - Wait for the first tick (up to ~60s after create), or publish a one-off test message to the same destination while keeping the window key stable.
- Check app logs for
idempotencyKey+ RovelaemailId. Optionally correlate later with Rovela webhooks.
Prefer sending_access on production senders — never full_access (Authentication). Never log API keys or signing keys. Rotate Rovela by create-then-revoke; rotate QStash signing keys per Upstash console guidance (current + next exist so rotation does not break in-flight verifies).
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
Handler 401 before Rovela | Missing / invalid Upstash-Signature | Confirm current + next signing keys; use raw body; match destination URL in sub (signature) |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix server secret; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Back off and retry with the same Idempotency-Key |
| Duplicate digests | Retries / double schedule without a stable key | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Schedule never fires | Bad cron, wrong region host, or destination URL typo | Check console schedules; cron.guru for the expression; max 1-minute resolution |
| Verify fails after JSON parse | Body re-serialized | Verify on req.text() / raw bytes first |
| Signing keys in Git | Keys committed or forwarded via QStash headers | Keep only in platform secrets; never Upstash-Forward- the Rovela key |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, server-only- QStash schedule with explicit
scheduleIdand documented cron (CRON_TZif not UTC) - Endpoint verifies
Upstash-Signature(current + next keys) before any side effects Idempotency-Keyscoped to the schedule window (day / hour / 15-min bucket)- Handler returns 2xx after Rovela accept so unnecessary retries stop; still safe if QStash retries
- Logs show successful
emailIdwithout printing secrets - Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients - Optional: QStash failure callback for exhausted retries (ops alert — still no Rovela key in forward headers)
FAQ
Can QStash call Rovela directly on a schedule? Technically you could schedule https://api.rovela.dev/emails, but you would have to forward Authorization and build the body at schedule-create time — that freezes content and risks exposing the sending key in QStash message storage. Prefer your verified endpoint: it aggregates live data and keeps sending_access local.
Should I use Rovela scheduled_at from the QStash handler? Usually no. If QStash already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without another wake (Scheduled Email Sending).
QStash Schedules vs platform cron vs Supabase pg_cron? Use QStash when you want an HTTP-native scheduler with retries across any host. Use Vercel/Render/K8s cron when the schedule should live next to that platform’s compute. Use Supabase pg_cron when the schedule should live next to Postgres (Supabase pg_cron Email).
What key scope does sending_access cover? Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the QStash handler on sending_access.
Does a 200 mean the founder already got the digest? No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).
Why both QStash retries and Rovela Idempotency-Key? QStash retries the HTTP invoke when your endpoint fails. Rovela’s key collapses duplicate sends if the handler succeeds twice in the same window (or times out after Rovela accepted). You want both layers.
Ship the schedule; let Rovela deliver
Upstash QStash Schedules give you HTTP cron (with optional CRON_TZ) that wakes any public HTTPS endpoint, with retries and console-managed pause/resume. Your handler owns signature verification, the live digest, server-only sending_access, and a window-scoped Idempotency-Key. Rovela owns HTTPS accept, DKIM, and delivery. Keep keys off the schedule body, verify every request, and you get transactional digests without standing up SMTP or a custom mail stack.
Next: verify a domain, create a sending_access key, deploy a signature-checked endpoint, create a QStash schedule with a stable id, and confirm the first fire in your logs.