Skip to main content

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

ConcernGuarantee
Occurrence materializationExactly once per (scheduleId, scheduledFor), enforced by a database uniqueness constraint.
Artifact generationConditional claim plus a stable artifact key prevents concurrent generation; a worker loss may cause safe re-execution.
External deliveryAt least once. Email receives a stable provider idempotency key; SFTP uses temporary-upload then rename; Slack stores its provider result immediately.
Schedule editsA run uses the specSnapshot frozen when the occurrence is materialized.
Queue lossThe reaper reconstructs pending generation and delivery jobs from database state.
Partial failureEvery 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

RoutePurpose
GET /v1/schedules?ownerUserId=&limit=List schedules visible to the delegated user or service administrator.
POST /v1/schedulesAtomically create the schedule and its initial targets.
GET /v1/schedules/:idRead recurrence, state, and target configuration.
PATCH /v1/schedules/:idEdit metadata or recurrence, or pause/resume. Requires expectedVersion.
DELETE /v1/schedules/:idDelete the schedule and retained history.
POST /v1/schedules/:id/targetsAdd a validated Email, SFTP, or Slack target.
PUT /v1/schedules/:id/targets/:targetIdReplace a target's validated configuration, secret reference, or enabled state.
DELETE /v1/schedules/:id/targets/:targetIdRemove a target that is no longer needed.
POST /v1/schedules/:id/runMaterialize an immediate manual occurrence.
GET /v1/schedules/:id/runsList occurrences with each target's current outcome.
GET /v1/schedules/:id/runs/:runIdRead one run, deliveries, and append-only attempt evidence.
GET /v1/schedules/:id/runs/:runId/artifactDownload an artifact as an authenticated owner/admin.
POST /v1/deliveries/:deliveryId/retryManually 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:

  • skip records missed runs as skipped and advances.
  • run_latest records older gaps and executes only the latest missed run.
  • run_all executes every missed run inside catchupWindowSeconds and 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 outcomeMeaningResponse
400 invalid_schedule / invalid_reportInvalid recurrence, limits, report contract, or target configurationFix the definition; do not retry unchanged.
401 / 403Missing/revoked credential or a credential of the wrong kindRe-authenticate or rotate the application key.
404 not_foundUnknown resource, other organization, or another delegated ownerReturn a neutral not-found response; do not probe.
409 version_conflictAnother actor changed the scheduleReload and explicitly reconcile the change.
409 not_retryableDelivery is not failed or deadRead current run state; do not force a duplicate.
503 schedule_unavailableExecution loops are disabled or unavailableOperator action; creating or editing definitions does not prove they will execute.
Delivery failedTransient provider/generator/storage/network outcome with nextAttemptAtLet database-driven retry proceed; do not create another schedule occurrence.
Delivery deadPermanent classification or attempts exhaustedCorrect 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 variableDefaultMeaning
ZUI_CLOUD_SCHEDULE_ENABLEDtrueStarts sweep, worker, and recovery loops. The HTTP API remains available.
ZUI_CLOUD_SCHEDULE_REDIS_URLunsetEnables the Redis execution buffer. Unset uses a bounded local buffer; durable recovery still comes from SQLite.
ZUI_CLOUD_SCHEDULE_SWEEP_INTERVAL_MS60000Schedule scan interval.
ZUI_CLOUD_SCHEDULE_LOOKAHEAD_MS120000Short materialization horizon.
ZUI_CLOUD_SCHEDULE_REAPER_INTERVAL_MS300000Database recovery interval.
ZUI_CLOUD_SCHEDULE_STALE_THRESHOLD_MS900000Claim age considered orphaned. Set above provider p99 latency.
ZUI_CLOUD_SCHEDULE_JITTER_WINDOW_SECONDS300Stable per-schedule execution spread after the nominal time.
ZUI_CLOUD_SCHEDULE_MAX_CONSECUTIVE_FAILURES5Failed runs before automatic suspension.
ZUI_CLOUD_SCHEDULE_ARTIFACT_DIR./data/artifactsLocal artifact store. Put it on persistent disk and include it in backups.
ZUI_CLOUD_SCHEDULE_DELIVERY_MODEsimulatesimulate records safe deterministic successes; live enables provider adapters.
ZUI_CLOUD_SCHEDULE_PUBLIC_BASE_URLunsetPublic ZUI Cloud origin used in signed artifact links.
ZUI_CLOUD_SCHEDULE_SIGNING_SECRETunsetHMAC secret for short-lived artifact links.
ZUI_CLOUD_SCHEDULE_GENERATOR_ORIGINShttp://127.0.0.1:8081Exact 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:

  1. Deploy one active ZUI Cloud process with SQLite and ZUI_CLOUD_SCHEDULE_ARTIFACT_DIR on local persistent disk.
  2. 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.
  3. 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.
  4. Restrict egress to exact generator/provider destinations. Validate SFTP DNS and host-key pins from the runtime network.
  5. Configure ZUI_CLOUD_SCHEDULE_PUBLIC_BASE_URL and a high-entropy signing secret only if signed-link delivery is required. Verify links through the external TLS proxy and test expiry/rotation behavior.
  6. Enable live in 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.