← Back to blog

DigitalOcean App Platform Jobs Transactional Email with Rovela (2026): SCHEDULED Cron + Encrypted Env + Idempotency-Key per Window

Wire DigitalOcean App Platform Scheduled Jobs (kind: SCHEDULED, five-field cron + time_zone, min every 15 minutes) to Rovela POST /emails: Encrypt/SECRET RUN_TIME env for sending_access, a run_command that exits, and a window-scoped Idempotency-Key so redeploys do not duplicate digests.

Updated November 5, 2026
DigitalOcean App Platform Jobs Transactional Email with Rovela (2026): SCHEDULED Cron + Encrypted Env + Idempotency-Key per Window

DigitalOcean App Platform Jobs own the cron — Rovela delivers the mail

DigitalOcean App Platform Jobs let you attach a Job component that runs on a recurring schedule (kind: SCHEDULED) or around deploys (PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY). Scheduled jobs use a five-field cron expression plus a tz-database time_zone, with a minimum interval of every 15 minutes. Like workers, jobs are not routable — they run in the background and cannot accept HTTP — and scheduled runs are billed only while the job is executing.

That shape fits digests, ops reports, and other transactional mail whose body depends on live state at fire time — signup 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 one-shot run_command calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). App Platform owns the schedule, container lifecycle, and environment variables (including Encrypt / type: SECRET). Your command owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires DigitalOcean App Platform Scheduled Jobs → Rovela that:

  1. Stores ROVELA_API_KEY, MAIL_FROM, and recipients in Job (or app-level) env vars — mark the API key Encrypt / SECRET, scope RUN_TIME
  2. Ships a one-shot run_command that starts, sends, and exits (jobs are not long-running workers)
  3. Aggregates a digest / report for the current schedule window
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives redeploys, manual re-runs, and overlapping ticks without duplicate mail

Rovela does not create DigitalOcean apps, Job components, or encrypted env vars. App Platform owns schedule → container start/exit. Your command owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from deploy-time App Platform jobs (PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY) — those fire around deployments, not on a calendar. This post is the kind: SCHEDULED Job → run_command → Rovela path.

What App Platform Jobs do vs what Rovela sends

ConcernOwner
Five-field cron + time_zone (min every 15 minutes)App Platform Job kind: SCHEDULED (manage jobs, app spec)
Non-routable container that runs run_command to completionApp Platform
Env vars / Encrypt (type: SECRET) for ROVELA_API_KEYComponent or app-level envs (environment variables)
Query DB / APIs for the windowYour run_command
Render HTML + textYour script
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = do-job-{kind}:{yyyy-mm-dd} (or hour / 15-min bucket)Your script

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

App Platform constraints that shape the email job

From DigitalOcean docs, treat these as hard requirements for a mail-sending scheduled Job:

  • kind: SCHEDULED + schedule.cron + schedule.time_zone. Cron is the classic five-field form; time_zone comes from the tz database (for example UTC, America/New_York, Asia/Shanghai) (manage jobs, app spec).
  • Minimum every 15 minutes. Expressions denser than */15 * * * * are not supported for scheduled jobs (manage jobs).
  • Jobs are not routable. They cannot accept HTTP/HTTPS — do not put a webhook receiver on the Job itself; call outbound HTTPS to Rovela from inside run_command (glossary: Job).
  • Billed only while running. Idle time between scheduled fires is not billed for scheduled jobs (manage jobs).
  • run_command must finish and exit. Jobs run a process to completion. Keep the job short (query + one HTTPS send), or enqueue longer work onto a Worker and exit. A stuck process burns instance time.
  • Secrets via Encrypt / type: SECRET. Prefer component-level RUN_TIME encrypted vars for ROVELA_API_KEY. Encrypted values are hidden from build/deploy/app logs in the control panel flow, but teammates with console access can still read the runtime env — limit Owner/Member roles accordingly (environment variables).
  • Control panel or app spec. In the UI: Resource type Job, Job trigger On a schedule, set the cron. In YAML: jobs[].kind: SCHEDULED with schedule (manage jobs).
  • Observability. Activity → Jobs in the control panel, or GET /v2/apps/{app_id}/job-invocations via the API / doctl (manage jobs).
  • Secrets stay server-side. Prefer a Rovela sending_access key — never ship it in a browser bundle or public Git (Authentication).
  • Outbound SMTP is a poor fit. Prefer HTTPS POST /emails on 443 (Email API vs SMTP).

When to use App Platform Jobs + POST /emails (not scheduled_at)

Use SCHEDULED Job → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run the app on DigitalOcean App Platform and want the same app spec, envs, and Activity logs
  • A ≥15-minute cron with an explicit time_zone is enough
  • 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). App Platform Jobs and scheduled_at solve different problems; this post is the DigitalOcean path.

Use a long-lived Worker only when you need a continuous process (queue consumer). For calendar digests, a SCHEDULED Job that exits is the cheaper, clearer fit.

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 Job (Authentication).
  3. A DigitalOcean App Platform app that can build your repo / image and run a short command with outbound HTTPS.
  4. Job (or app-level) env vars for ROVELA_API_KEY (Encrypt / SECRET), MAIL_FROM, DIGEST_TO.
  5. A run_command that exits 0 after a successful accept (or a deliberate no-op when there is nothing to mail).

App spec: SCHEDULED Job + encrypted env

Minimal job fragment (attach alongside your existing services/workers). Adjust github / git / image, environment_slug, and instance size to match your app:

jobs:
  - name: daily-digest
    kind: SCHEDULED
    schedule:
      cron: "0 13 * * *"          # 13:00 in time_zone below
      time_zone: America/New_York
    github:
      repo: your-org/your-app
      branch: main
      deploy_on_push: true
    source_dir: /
    environment_slug: node-js
    # Overrides Dockerfile entrypoint when set; must exit when send finishes
    run_command: node bin/send-daily-digest.js
    instance_count: 1
    instance_size_slug: apps-s-1vcpu-0.5gb
    envs:
      - key: MAIL_FROM
        scope: RUN_TIME
        type: GENERAL
        value: "Acme Reports <reports@mail.example.com>"
      - key: DIGEST_TO
        scope: RUN_TIME
        type: GENERAL
        value: "founders@example.com"
      - key: DIGEST_KIND
        scope: RUN_TIME
        type: GENERAL
        value: daily
      - key: ROVELA_API_KEY
        scope: RUN_TIME
        type: SECRET
        value: "re_your_sending_access_key"   # encrypted on first submit; reuse EV[…] thereafter

Apply with the control panel (Add components → Job → On a schedule) or:

doctl apps update <app-id> --spec ./app.yaml

Prefer the Dashboard Encrypt checkbox (or type: SECRET) for ROVELA_API_KEY. Keep sending_access on production senders — never full_access (Authentication). Never log the API key. After the first submit, the app spec stores an EV[…] ciphertext — submit that encrypted value on later updates rather than pasting plaintext again (app spec envs).

Every-15-minutes ops pulse example:

schedule:
  cron: "*/15 * * * *"
  time_zone: UTC

One-shot Node.js script (window-scoped Idempotency-Key)

Add a script your Job run_command can execute:

// bin/send-daily-digest.js
'use strict';

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
}

function windowKey(kind) {
  if (kind === 'hourly') return `do-job-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 `do-job-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
  }
  return `do-job-daily-digest:${utcDayKey()}`;
}

async function buildDailyDigest(day) {
  // 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({ 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 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);

  console.log(
    JSON.stringify({
      kind,
      day,
      idempotencyKey,
      // never log ROVELA_API_KEY
    }),
  );

  const result = await sendTransactional({
    to,
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    idempotencyKey,
    tags: {
      kind: `do-job-${kind}-digest`,
      day,
      platform: 'digitalocean-app-platform-jobs',
    },
  });

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

main().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 App Platform because:

  • A Job may be re-triggered while you debug run_command
  • Cron boundaries and redeploys can re-enter the same calendar window
  • Manual console / one-off invocations can overlap a scheduled fire

Recommended keys:

KeyUse when
do-job-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
do-job-hourly-ops:{yyyy-mm-dd}T{HH}Hourly snapshot (0 * * * *)
do-job-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm}Every-15-minutes pulse (mm floored to 00/15/30/45)
do-job-weekly-report:{yyyy}-W{ww}One founder report per ISO week
do-job-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. Keep wall time short so the Job container can exit cleanly.

Python alternative

#!/usr/bin/env python3
# bin/send_daily_digest.py
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone

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(now: datetime | None = None) -> str:
    now = now or datetime.now(timezone.utc)
    return now.strftime("%Y-%m-%d")


def window_key(kind: str) -> str:
    now = datetime.now(timezone.utc)
    if kind == "hourly":
        return f"do-job-hourly-ops:{now.strftime('%Y-%m-%dT%H')}"
    if kind == "fifteen":
        bucket = (now.minute // 15) * 15
        return f"do-job-fifteen-ops:{now.strftime('%Y-%m-%dT%H')}:{bucket:02d}"
    return f"do-job-daily-digest:{utc_day_key(now)}"


def build_daily_digest(day: str) -> dict:
    stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
    subject = f"Acme daily digest — {day}"
    text = (
        f"Signups: {stats['signups']}\n"
        f"Paid: {stats['paid']}\n"
        f"Open incidents: {stats['incidents']}\n"
        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>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
    """
    return {"subject": subject, "html": html, "text": text}


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("utf-8"),
        headers={
            "Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.load(resp)
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", errors="replace")
        raise SystemExit(f"Rovela {e.code}: {body}") from e


def main() -> None:
    kind = os.environ.get("DIGEST_KIND", "daily")
    day = utc_day_key()
    digest = build_daily_digest(day)
    to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
    idem = window_key(kind)
    print(json.dumps({"kind": kind, "day": day, "idempotencyKey": idem}))
    result = send_transactional(
        to=to,
        subject=digest["subject"],
        html=digest["html"],
        text=digest["text"],
        idempotency_key=idem,
        tags={
            "kind": f"do-job-{kind}-digest",
            "day": day,
            "platform": "digitalocean-app-platform-jobs",
        },
    )
    print(json.dumps({"ok": True, "emailId": result.get("id")}))


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

Set run_command: python bin/send_daily_digest.py (or your image’s interpreter path) on the Job component.

Attach and verify the Job

  1. Deploy the script with your usual App Platform push / doctl apps update.
  2. Confirm env vars on the Job: ROVELA_API_KEY encrypted, MAIL_FROM / DIGEST_TO set, scope RUN_TIME.
  3. In Settings → Job → Job trigger, choose On a schedule (or confirm kind: SCHEDULED in the spec) with cron + time zone.
  4. Watch Activity → Jobs, or list invocations:
curl -s -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
  "https://api.digitalocean.com/v2/apps/<app-id>/job-invocations"

You should see the Job start, your JSON log line with idempotencyKey, and a completed invocation. Store the Rovela emailId if you correlate against webhooks later.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Rovela 401Bad / missing ROVELA_API_KEY, or used full_access where only send is neededReset the encrypted env; confirm sending_access (Authentication)
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains (Quick Start)
Rovela 429Rate limitBack off and retry with the same Idempotency-Key
Job never firesCron denser than 15 minutes, wrong kind, or wrong componentUse ≥ */15; confirm kind: SCHEDULED and Job trigger On a schedule
Duplicate digestsRe-run / redeploy in the same window without a stable keyUse window-scoped keys as above; Idempotency-Key collapses duplicates within 24h
Job burns instance timerun_command never exits (e.g. started a server)Exit after send; use a Worker for long-lived processes
Secret in build logsKey scoped BUILD_TIME / RUN_AND_BUILD_TIME or not EncryptUse RUN_TIME + Encrypt / type: SECRET

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, Encrypt / SECRET, RUN_TIME only
  • Job kind: SCHEDULED with five-field cron ≥ every 15 minutes and an explicit time_zone
  • run_command exits after send (or after a deliberate empty-window no-op)
  • Idempotency-Key scoped to the schedule window (day / hour / 15-min bucket)
  • Activity → Jobs (or job-invocations API) shows successful completions
  • Team console access limited for encrypted env hygiene
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can I run every 5 minutes?
No — scheduled App Platform Jobs require at least every 15 minutes (manage jobs). Keep the same Rovela helper and pick another scheduler if you need denser ticks.

Should I use Rovela scheduled_at from the Job?
Usually no. If the Job 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 a container awake (Scheduled Email Sending).

Job vs Worker vs Service for digests?
Use a SCHEDULED Job for calendar digests that start → exit. Use a Worker for continuous queue consumers. Use a Service only for HTTP APIs — jobs are not routable (glossary: Job).

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 Job 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).

Ship the schedule; let Rovela deliver

DigitalOcean App Platform Jobs give you first-class five-field cron (with time_zone) on short-lived, non-routable containers. Your run_command owns the live digest and a window-scoped Idempotency-Key. Rovela owns HTTPS accept, DKIM, and delivery. Encrypt the sending key, exit cleanly, and you get transactional digests without standing up SMTP or a custom mail stack.

Next: verify a domain, create a sending_access key, add a kind: SCHEDULED Job with encrypted env vars, deploy the one-shot script, and confirm the first invocation under Activity → Jobs.