Scheduled Delivery
Scheduled Delivery is the ZUI Cloud module for turning an authorized report definition into recurring or one-shot artifacts and delivering each occurrence to Email, SFTP, or Slack. Schedules, occurrences, targets, attempts, and outcomes are durable database records. Redis is only an execution buffer: clearing it does not delete scheduled work.
trusted application backend
│ POST /v1/schedules
▼
schedule ──▶ run occurrence ──▶ one generated artifact
│
┌──────────────┼──────────────┐
▼ ▼ ▼
Email SFTP Slack
own attempts own attempts own attempts
This separation matters. Generation runs once, while a temporary SFTP outage can retry without delaying or repeating a successful email delivery.
Core guarantees
| Concern | Guarantee |
|---|---|
| Occurrence materialization | Exactly once per (scheduleId, scheduledFor), enforced by a database uniqueness constraint. |
| Artifact generation | Conditional claim plus a stable artifact key prevents concurrent generation; a worker loss may cause safe re-execution. |
| External delivery | At least once. Email receives a stable provider idempotency key; SFTP uses temporary-upload then rename; Slack stores its provider result immediately. |
| Schedule edits | A run uses the specSnapshot frozen when the occurrence is materialized. |
| Queue loss | The reaper reconstructs pending generation and delivery jobs from database state. |
| Partial failure | Every target has an independent status, attempt count, retry time, and terminal outcome. |
Exactly-once delivery to an external provider is impossible when a worker can stop after the provider commits but before the local success write. The module therefore exposes the honest guarantee and uses channel-specific duplicate mitigations.
Create a schedule
Call ZUI Cloud from a trusted backend. Keep the organization API key and provider credentials out of browser code.
POST /v1/schedules
Authorization: Bearer zui_pk_...
X-ZUI-User-ID: customer-user-42
Content-Type: application/json
{
"name": "Monday quality brief",
"kind": "cron",
"cronExpression": "0 8 * * 1",
"timezone": "Europe/Berlin",
"reportType": "zui-view",
"reportParams": {
"view": { "version": 2, "id": "quality", "root": { "type": "stack" } },
"datasets": { "events": [] },
"page": { "format": "A4", "landscape": true }
},
"outputFormat": "pdf",
"catchupPolicy": "skip",
"targets": [
{
"type": "email",
"credentialRef": "env:REPORT_EMAIL",
"config": {
"to": ["quality@example.com"],
"cc": [],
"subjectTemplate": "{{report_name}} — {{scheduled_for | date}}",
"bodyTemplate": "Your controlled report is ready.",
"deliveryMode": "link"
}
}
]
}
Use an IANA timezone on the schedule itself. A five-field cron expression has minute precision. During spring-forward, a nonexistent local time fires at the first valid instant after the gap. During fall-back, an overlapping local time fires once, on its first occurrence.
For a one-shot delivery, send kind: "once" and an offset-bearing runAt
timestamp instead of cronExpression.
Creation returns 201 {schedule} and X-Schedule-Version: 1. A schedule
requires 1–20 targets. Names are limited to 200 characters,
catchupWindowSeconds to 60–2592000, and maxOccurrences to
1–1000000. The stored nextRunAt and later executionAt are UTC instants;
the IANA timezone controls recurrence calculation.
When X-ZUI-User-ID is present, that delegated user owns the schedule and can
list, read, edit, run, and delete only its own schedules. Scheduled Delivery
does not use X-ZUI-Group-IDs. An organization API key without a delegated
user and an organization-owner session are service administrators within that
organization. Construct delegated identity only after authenticating the
consumer application's user.
Generate with current application data
A recurring report normally needs fresh, policy-filtered data rather than the
rows present when the schedule was created. Use reportType: "zui-view-url"
to let ZUI Cloud request a render payload from a protected endpoint at
execution time:
{
"reportType": "zui-view-url",
"reportParams": {
"url": "https://app.example.com/internal/scheduled-report",
"credentialRef": "env:REPORT_GENERATOR_SECRET",
"request": {
"userId": "customer-user-42",
"pageId": "quality",
"region": "EMEA"
}
},
"outputFormat": "pdf"
}
The origin must be allow-listed with ZUI_CLOUD_SCHEDULE_GENERATOR_ORIGINS. The worker
resolves the env: credential at use time, posts the frozen request, validates
the returned PDF render payload, and then renders it. The application endpoint
should re-resolve the user, current configuration head, permissions, and data
on every request. The deployable
Aegis and Cedarline reference applications
demonstrate this complete pattern in two different industries.
For tabular artifacts, use outputFormat: "csv" or "xlsx" and provide
reportParams.rows (or reportParams.data) as an array of objects. "json"
serializes reportParams.data, falling back to the complete params object.
The generator response must be one complete PDF render payload: a view or
app, plus any authorized datasets, schemas, parameters, filters, state,
theme, and page options. The worker limits the response body to 10 MiB and the
request to 30 seconds. The endpoint must return 2xx JSON and must never trust
the frozen user ID alone—re-resolve the principal, active account, current
configuration head, current row/field policy, and requested resource on every
occurrence.
Target configuration
Credentials never live in target configuration. credentialRef is a pointer
such as env:QUALITY_SFTP; its environment value is a JSON secret resolved by
the delivery worker.
Templates support {{report_name}}, {{scheduled_for}}, and
{{scheduled_for | date}}. Template fields are plain strings, not a general
expression language. Validate rendered SFTP names because path separators are
rejected.
Email
{
"type": "email",
"credentialRef": "env:REPORT_EMAIL",
"config": {
"to": ["ops@example.com"],
"cc": [],
"subjectTemplate": "{{report_name}} — {{scheduled_for | date}}",
"bodyTemplate": "The report is ready.",
"deliveryMode": "link",
"fromAddress": "reports@example.com"
}
}
Live email uses a Resend-compatible HTTPS endpoint. The secret is
{"apiKey":"...","providerUrl":"https://api.resend.com/emails"}. The
stable delivery idempotency key is forwarded as Idempotency-Key. Link mode
uses a signed, expiring artifact URL when public URL and signing settings are
configured; otherwise the worker safely attaches the file.
The current signed-link lifetime is seven days. Rotating
ZUI_CLOUD_SCHEDULE_SIGNING_SECRET immediately invalidates previously issued links.
Choose attachment mode when recipients cannot reach the public base URL or
when that expiry does not meet the workflow's requirement.
SFTP
{
"type": "sftp",
"credentialRef": "env:PARTNER_SFTP",
"config": {
"host": "sftp.partner.com",
"port": 22,
"username": "acme_reports",
"remoteDirectory": "/inbound/reports",
"filenameTemplate": "quality_{{scheduled_for | date}}.pdf",
"authMethod": "publickey",
"hostKeyFingerprint": "SHA256:approved-fingerprint",
"createDirectory": false,
"onConflict": "overwrite"
}
}
The worker rejects private/link-local destinations, pins the approved host key,
uploads .{filename}.part-{deliveryId}, verifies its size, and renames it to
the final path as the atomic commit point. The secret contains privateKey and
optional passphrase, or password for password authentication.
Slack
Slack webhook mode posts a signed artifact link and resolves
{"webhookUrl":"https://hooks.slack.com/..."}. file mode uses Slack's
external-upload flow with {"botToken":"xoxb-..."} and requires files:write
and chat:write, plus the appropriate conversation-history scope so a retry
can detect an already committed upload.
Webhook mode requires ZUI_CLOUD_SCHEDULE_PUBLIC_BASE_URL and
ZUI_CLOUD_SCHEDULE_SIGNING_SECRET to deliver the artifact link. Without them, the
message can be delivered but contains no artifact. File mode uploads the
artifact itself and is the safer default for private deployments with no
public artifact origin.
{
"type": "slack",
"credentialRef": "env:QUALITY_SLACK",
"config": {
"workspaceId": "T01234567",
"channelId": "C01234567",
"mode": "file",
"messageTemplate": "{{report_name}} for {{scheduled_for | date}}",
"threadBroadcast": false
}
}
Lifecycle and history API
| Route | Purpose |
|---|---|
GET /v1/schedules?ownerUserId=&limit= | List schedules visible to the delegated user or service administrator. |
POST /v1/schedules | Atomically create the schedule and its initial targets. |
GET /v1/schedules/:id | Read recurrence, state, and target configuration. |
PATCH /v1/schedules/:id | Edit metadata or recurrence, or pause/resume. Requires expectedVersion. |
DELETE /v1/schedules/:id | Delete the schedule and retained history. |
POST /v1/schedules/:id/targets | Add a validated Email, SFTP, or Slack target. |
PUT /v1/schedules/:id/targets/:targetId | Replace a target's validated configuration, secret reference, or enabled state. |
DELETE /v1/schedules/:id/targets/:targetId | Remove a target that is no longer needed. |
POST /v1/schedules/:id/run | Materialize an immediate manual occurrence. |
GET /v1/schedules/:id/runs | List occurrences with each target's current outcome. |
GET /v1/schedules/:id/runs/:runId | Read one run, deliveries, and append-only attempt evidence. |
GET /v1/schedules/:id/runs/:runId/artifact | Download an artifact as an authenticated owner/admin. |
POST /v1/deliveries/:deliveryId/retry | Manually retry a failed or dead target. |
Schedule edits use optimistic concurrency. A stale expectedVersion receives
409 version_conflict and details.currentVersion; the service does not
silently overwrite another editor.
PATCH can change the name, lifecycle state, recurrence, timezone, catch-up
settings, and start/end/occurrence limits. Target changes use the subordinate
target routes. The current API does not patch reportType, reportParams, or
outputFormat; create a replacement schedule when the report definition must
change, verify it, and then remove the old schedule.
Run now and inspect evidence
POST /v1/schedules/:id/run returns 202 {run} after materializing an
immediate occurrence. Poll the run resource until it reaches a terminal state:
const terminal = new Set([
'completed', 'partially_failed', 'failed', 'skipped', 'cancelled',
])
async function waitForRun(scheduleId: string, runId: string) {
for (let attempt = 0; attempt < 60; attempt += 1) {
const response = await fetch(
`${process.env.ZUI_CLOUD_URL}/v1/schedules/${scheduleId}/runs/${runId}`,
{headers: {Authorization: `Bearer ${process.env.ZUI_CLOUD_KEY}`}},
)
if (!response.ok) throw new Error(`Run lookup failed (${response.status})`)
const {run} = await response.json()
if (terminal.has(run.status)) return run
await new Promise((resolve) => setTimeout(resolve, 2_000))
}
throw new Error('Run did not complete before the caller deadline')
}
Use the returned delivery IDs, statuses, nextAttemptAt, provider message ID,
and append-only attemptsLog for operator evidence. Download an artifact with
the authenticated run artifact route; do not construct a signed URL yourself.
Missed occurrences and retry behavior
catchupPolicy is explicit:
skiprecords missed runs asskippedand advances.run_latestrecords older gaps and executes only the latest missed run.run_allexecutes every missed run insidecatchupWindowSecondsand records older gaps as skipped.
Delivery retry state is database-driven. Transient failures become failed
with a visible nextAttemptAt; the reaper re-enqueues them with exponential
full-jitter backoff, honoring a provider's Retry-After. Permanent failures
become dead immediately. A run closes as completed, partially_failed, or
failed only after all targets are terminal. Repeated unsuccessful runs
automatically suspend the schedule.
The default delivery maximum is five attempts. Manual retry accepts only
failed or dead deliveries and returns 202; it does not erase prior
attempts. Resuming a suspended schedule is an explicit update and should happen
only after the target, credential, provider, or network failure has been
corrected.
Failure handling
| API or runtime outcome | Meaning | Response |
|---|---|---|
400 invalid_schedule / invalid_report | Invalid recurrence, limits, report contract, or target configuration | Fix the definition; do not retry unchanged. |
401 / 403 | Missing/revoked credential or a credential of the wrong kind | Re-authenticate or rotate the application key. |
404 not_found | Unknown resource, other organization, or another delegated owner | Return a neutral not-found response; do not probe. |
409 version_conflict | Another actor changed the schedule | Reload and explicitly reconcile the change. |
409 not_retryable | Delivery is not failed or dead | Read current run state; do not force a duplicate. |
503 schedule_unavailable | Execution loops are disabled or unavailable | Operator action; creating or editing definitions does not prove they will execute. |
Delivery failed | Transient provider/generator/storage/network outcome with nextAttemptAt | Let database-driven retry proceed; do not create another schedule occurrence. |
Delivery dead | Permanent classification or attempts exhausted | Correct the cause, then use explicit retry when appropriate. |
Do not treat a 202 manual-run or retry response as delivery success. It only
confirms durable acceptance. Observe the terminal run/delivery evidence.
Runtime configuration
| Environment variable | Default | Meaning |
|---|---|---|
ZUI_CLOUD_SCHEDULE_ENABLED | true | Starts sweep, worker, and recovery loops. The HTTP API remains available. |
ZUI_CLOUD_SCHEDULE_REDIS_URL | unset | Enables the Redis execution buffer. Unset uses a bounded local buffer; durable recovery still comes from SQLite. |
ZUI_CLOUD_SCHEDULE_SWEEP_INTERVAL_MS | 60000 | Schedule scan interval. |
ZUI_CLOUD_SCHEDULE_LOOKAHEAD_MS | 120000 | Short materialization horizon. |
ZUI_CLOUD_SCHEDULE_REAPER_INTERVAL_MS | 300000 | Database recovery interval. |
ZUI_CLOUD_SCHEDULE_STALE_THRESHOLD_MS | 900000 | Claim age considered orphaned. Set above provider p99 latency. |
ZUI_CLOUD_SCHEDULE_JITTER_WINDOW_SECONDS | 300 | Stable per-schedule execution spread after the nominal time. |
ZUI_CLOUD_SCHEDULE_MAX_CONSECUTIVE_FAILURES | 5 | Failed runs before automatic suspension. |
ZUI_CLOUD_SCHEDULE_ARTIFACT_DIR | ./data/artifacts | Local artifact store. Put it on persistent disk and include it in backups. |
ZUI_CLOUD_SCHEDULE_DELIVERY_MODE | simulate | simulate records safe deterministic successes; live enables provider adapters. |
ZUI_CLOUD_SCHEDULE_PUBLIC_BASE_URL | unset | Public ZUI Cloud origin used in signed artifact links. |
ZUI_CLOUD_SCHEDULE_SIGNING_SECRET | unset | HMAC secret for short-lived artifact links. |
ZUI_CLOUD_SCHEDULE_GENERATOR_ORIGINS | http://127.0.0.1:8081 | Exact comma-separated origins allowed for dynamic report generation. |
Self-host Scheduled Delivery
Follow the complete Cloud Private operations runbook. Scheduled Delivery adds durable artifacts, background execution, and external egress to the shared ZUI Cloud threat model.
Use this go-live sequence:
- Deploy one active ZUI Cloud process with SQLite and
ZUI_CLOUD_SCHEDULE_ARTIFACT_DIRon local persistent disk. - Keep
ZUI_CLOUD_SCHEDULE_DELIVERY_MODE=simulate. Create a representative schedule, force a manual run, restart the process mid-test, and verify the reaper reconstructs durable work. - Add generator and provider credentials as
env:values in the runtime secret source. Restart after changing the environment; the process does not reread its environment file dynamically. - Restrict egress to exact generator/provider destinations. Validate SFTP DNS and host-key pins from the runtime network.
- Configure
ZUI_CLOUD_SCHEDULE_PUBLIC_BASE_URLand a high-entropy signing secret only if signed-link delivery is required. Verify links through the external TLS proxy and test expiry/rotation behavior. - Enable
livein a non-production provider tenant, prove Email, SFTP, and Slack duplicate behavior, then promote through normal change control.
The local execution buffer is acceptable for the supported single-process
topology because the database reaper recreates pending work after restart.
Redis reduces reliance on process-local timers and should use authentication,
TLS where available, noeviction, persistence, capacity alerts, and a
deployment-specific key prefix. It does not make local SQLite or artifact
files available to multiple nodes and is not, by itself, an HA design.
The built-in artifact store writes mode-0600 files under
ZUI_CLOUD_SCHEDULE_ARTIFACT_DIR using a temporary file and atomic rename. A shared
object store requires implementing and wiring the ArtifactStore interface in
a custom build; there is no environment switch for S3-compatible storage in
this release.
Back up the database and artifact directory as one recovery set. Schedule deletion cascades its database history, but the filesystem artifact store does not currently remove the corresponding files. There is no automatic artifact or history retention job. Monitor growth and design a tested retention process that never removes an artifact still referenced by a run.
Alert on occurrence delay (executionAt versus actual start), runs remaining
non-terminal beyond the stale threshold, delivery retry backlog, dead
deliveries, automatic suspensions, generator latency/failure, provider rate
limits, artifact-disk capacity, Redis availability, and backup freshness. The
unauthenticated /health endpoint reports whether scheduling was configured,
not whether Redis, artifact storage, generators, or providers are healthy.
Continue with ZUI Cloud, PDF Export, Config Store, or the Cloud Private operations runbook.