← Back to blog

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.

Updated November 5, 2026
Upstash QStash Schedules Transactional Email with Rovela (2026): Signature Verify + Idempotency-Key per Window

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:

  1. Creates a schedule with Upstash-Cron (UTC by default, or CRON_TZ=… for an IANA timezone) and an optional stable Upstash-Schedule-Id
  2. Verifies every inbound request with the QStash Upstash-Signature JWT (Receiver / verifySignatureAppRouter) using current + next signing keys
  3. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients in server-only secrets — never in the browser or a public Git tree
  4. Aggregates a digest / report for the current schedule window
  5. Sends via Rovela with a window-scoped Idempotency-Key
  6. 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:

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

ConcernOwner
Cron expression (Upstash-Cron), optional CRON_TZ=IANA, schedule idQStash Schedules (schedules, Create a Schedule)
HTTPS POST (default) to destination URL / URL Group; retries / timeoutQStash delivery
Verify Upstash-Signature (JWT) with current + next signing keysYour endpoint (Verify Signatures)
Server secrets for ROVELA_API_KEY (sending_access)Your host / vault / platform env
Query DB / APIs for the windowYour endpoint
Render HTML + textYour endpoint
POST /emails, DKIM, queue, bouncesRovela
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 (or client.schedules.create({ destination, cron }) in the TypeScript SDK). Destination must be http:// or https:// (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 / scheduleId to 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-Signature JWT 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 via Idempotency-Key.
  • Keep the Rovela key off QStash. Do not put ROVELA_API_KEY in the schedule body or Upstash-Forward-* headers that land in logs. Keep sending_access only 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

  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 schedule handler (Authentication).
  3. An Upstash account with QStash enabled, a QStash token, and current + next signing keys from the console.
  4. A publicly reachable HTTPS endpoint (Next.js App Router, Hono on Cloudflare/Fly, etc.) that can verify signatures and call Rovela.
  5. 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:

KeyUse 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

  1. Deploy the verified endpoint; confirm QSTASH_* signing keys and ROVELA_API_KEY (sending_access) are set server-side only.
  2. Create (or overwrite) the schedule with a stable scheduleId.
  3. 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.
  4. Check app logs for idempotencyKey + Rovela emailId. 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

SymptomLikely causeWhat to do
Handler 401 before RovelaMissing / invalid Upstash-SignatureConfirm current + next signing keys; use raw body; match destination URL in sub (signature)
Rovela 401Bad / missing ROVELA_API_KEYFix server secret; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitBack off and retry with the same Idempotency-Key
Duplicate digestsRetries / double schedule without a stable keyUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Schedule never firesBad cron, wrong region host, or destination URL typoCheck console schedules; cron.guru for the expression; max 1-minute resolution
Verify fails after JSON parseBody re-serializedVerify on req.text() / raw bytes first
Signing keys in GitKeys committed or forwarded via QStash headersKeep only in platform secrets; never Upstash-Forward- the Rovela key

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, server-only
  • QStash schedule with explicit scheduleId and documented cron (CRON_TZ if not UTC)
  • Endpoint verifies Upstash-Signature (current + next keys) before any side effects
  • Idempotency-Key scoped 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 emailId without printing secrets
  • Optional: Rovela webhooks for email.bounced / email.complained on 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.