---
title: "Edit an export schedule."
method: PATCH
path: "/v1/dashboards/{id}/export-schedules/{sid}"
tags: ["exports"]
---

# Edit an export schedule.

`PATCH /v1/dashboards/{id}/export-schedules/{sid}`

## Request

- Base URL: `http://localhost:8080`
- URL: `http://localhost:8080/v1/dashboards/{id}/export-schedules/{sid}`
- Auth: one of:
  - API key in cookie `dvt_session`
  - HTTP bearer

Partial update (merge patch). Requires `dashboard:write`. Editable fields:
`title`, `cron` or `preset` (not both), `timezone`, `format`, `enabled`.
When `cron` or `timezone` changes, `next_run_at` is recomputed atomically.
Email and `is_external` on recipients are immutable — change by delete + re-add.

A `timezone` that cannot be resolved to an IANA zone is a **422** (DVT-4321), not a
400; the failing value is echoed in a `timezone` extension field. A blank `timezone`, or
`"Local"`, normalizes to `UTC` rather than being rejected.

## Request body

- PatchExportScheduleRequest: Body for PATCH /v1/dashboards/{id}/export-schedules/{sid}. All fields optional. Supply `cron` OR `preset` to update the recurrence — not both. `title: null` clears the label. When cron or timezone changes, `next_run_at` is recomputed atomically. `panelId: null` clears the panel scope (converts element-grain → whole-dashboard). `pageId: null` likewise clears the page scope; the effective post-patch row may not carry both a panel and a page scope (400).
  - `title` string, nullable
  - `cron` string
  - `preset` ExportSchedulePreset: A convenience preset that normalises to a canonical 5-field cron expression server-side (ADR-0051 §1). Provide either `preset` or `cron`, not both.
    - `kind` 'hourly' | 'daily' | 'weekly' | 'monthly', required
    - `atMinute` integer: Minute-of-hour (0-59). Defaults to 0 when omitted.
    - `atHour` integer: Hour-of-day (0-23) for daily/weekly/monthly presets.
    - `dayOfWeek` integer: Day of the week (0=Sunday, 6=Saturday) for the weekly preset.
    - `dayOfMonth` integer: Day of the month (1-28) for the monthly preset.
  - `timezone` string: IANA timezone name. A blank value, or `"Local"`, normalizes to `"UTC"` AND recomputes `nextRunAt` — it does not mean "leave the zone alone", which is what omitting the field does. Any other name that cannot be resolved is a 422.
  - `format` 'pdf' | 'png' | 'xlsx' | 'csv': Changing to, or editing a schedule that is, `xlsx`/`csv` requires `data:query` **and** `data:export` (403 without both; the free plan has neither; a pure `enabled: false` is exempt). xlsx/csv are email-only and never stored: the request is a 400 if the schedule has channel destinations or the request body carries a non-empty `destinations` field (an undeclared field, ignored for pdf/png).
  - `panelId` string, nullable: When set to a string, updates the panel scope; the effective format (this PATCH's `format` or, if absent, the existing one) must be `png`, or `xlsx`/`csv` with a `table` panel (sheet and other panel types return 400; `xlsx`/`csv` always need a panel scope). When set to `null`, clears the panel scope (whole-dashboard). Absent means no change.
  - `pageId` string, nullable: When set to a string, updates the page scope (page slug; must exist in the dashboard's current multi-page spec). When set to `null`, clears the page scope (whole-dashboard). Absent means no change. The effective post-patch row may not carry both a panel and a page scope (400).
  - `enabled` boolean

## Response `200`

Updated schedule.

- ExportScheduleEnvelope
  - `data` ExportSchedule, required: One recurring dashboard-export subscription (ADR-0051 §2). `recipients` and `destinations` are included only for `dashboard:write` actors (split read model — ADR-0051 §3). A read-only viewer sees schedule metadata only. `panelId` is metadata (not PII) and is visible to `dashboard:read` actors.
    - `id` string, uuid, required
    - `orgId` string, uuid, required
    - `dashboardId` string, uuid, required
    - `title` string, nullable
    - `cron` string, required: Canonical 5-field cron expression (always stored normalised).
    - `timezone` string, required: IANA timezone name; cron is evaluated in this timezone.
    - `format` 'pdf' | 'png' | 'email' | 'xlsx' | 'csv', required: `pdf`/`png`/`xlsx`/`csv` are artifact schedules (`xlsx`/`csv` are table-panel row exports, DVT-5200) (`deliveryKind: artifact`). `email` is an email-report schedule (`deliveryKind: email_report`, DVT-4266) — the two always agree, enforced by a DB CHECK. The export-schedule routes only ever return `pdf`/`png`/`xlsx`/`csv`; the email-schedule routes only ever return `email`.
    - `deliveryKind` 'artifact' | 'email_report': What this schedule delivers (DVT-4266). `artifact` — a rendered PDF/PNG delivered by the export runner; every schedule created before this field existed is `artifact`. `email_report` — a dashboard page rendered inline as HTML and sent via `SYSTEM$SEND_EMAIL`, managed through `/v1/dashboards/{id}/email-schedules`. The two surfaces are disjoint: an `email_report` schedule is never returned by, or actionable through, the export-schedule routes, and is never picked up by the export runner. Not in `required` (additive): older servers omit this field.
    - `emailOptions` object, nullable: Email-only settings for an `email_report` schedule; absent/null for artifact schedules (DVT-4266).
      - `subject` string: Subject override. Empty/absent means the server default ("dvt report: <dashboard title>[ · <page title>]"), computed at send time so a renamed dashboard is reflected without editing the schedule.
    - `lastOutcome` 'sent' | 'failed' | 'unconfirmed' | 'not-sent-timeout' | 'not-sent-failed' | 'not-sent-too-large' | 'not-sent-not-attempted', nullable: Outcome of the most recent fire. Null until the schedule has run at least once. The authoritative record of any send is the append-only `dashboard.email` audit row — this is a convenience copy for rendering the schedules list. The first six values are the frozen `dashboard.email` audit `outcome` spellings (DVT-4213, published in the native-app threat model §Boundary 8) and carry exactly their published meanings. They are only ever copied verbatim from the outcome the send path audited — no refusal that wrote no `dashboard.email` row can produce one, on either fire path (Run now or the unattended scheduled fire). The audit row is written on that same path and remains the authoritative record; it is observed rather than fail-closed (SEC-13), so a dropped audit write is alerted, not silently absent. `not-sent-not-attempted` is this field's own seventh value and never appears as an audit `outcome`. It means the run was refused before any send was attempted, so no `dashboard.email` row was written — no email integration is configured for the app or it is not authorized to the application (409), the schedule's `pageId` no longer exists in the dashboard's spec (400), or any server-side failure before a send was attempted — loading or parsing the dashboard, resolving its host connection, or rendering (500). On both fire paths the rule is 'schedule resolved, no audited outcome, and not caller-scoped', not a fixed list of statuses: it also covers an unreadable or empty recipient list, an invalid schedule timezone, a dashboard that no longer loads, and a dvt-side storage failure while the run is being prepared. dvt's due-tick logic (DVT-4793 S2) stamps this same value, once per occurrence, for the two candidates its `due` tick refuses to offer for a reason the schedule itself can fix: a timezone that is not a known IANA name ("invalid timezone — edit the schedule"), and a dispatcher-driven row whose recipients are not pinned ("recipients not pinned — re-activate"). Both re-arm the schedule's next occurrence exactly as any other candidate would, so this is not a repeating stamp. Several things are deliberately NOT stamped by the unattended fire: an idempotency non-event (the occurrence was already sent, a run is already in flight, or the task fired at a time the cadence does not name — stamping those would overwrite a real outcome on every non-matching day of a weekly or monthly cadence), a caller-scoped hop refusal (role mismatch, an invalid or replayed run token, or a malformed payload), a schedule whose cadence or timezone was edited since activation (it simply stops appearing as due until re-activated — the SPA derives "needs re-activation" by comparing the live cron/timezone against the activated pin, not from this field), and a candidate the dispatcher skips for being too stale to fire (recorded as a skipped run, not a schedule outcome). It deliberately names no cause: the 409 in particular is ambiguous between a stale app version and the *calling* role lacking `USAGE` on the application, and this value is shown to every viewer. (For a run started with Run now, an `email_schedule.run` audit row recording that the run was *triggered* is still written once the recipient list is resolved; the unattended scheduled fire writes no `email_schedule.run` row. On either path it is the egress row that is absent.) A run refused for a CALLER-scoped reason (401/403/404 — the principal lacks `data:export`, or has no live Snowflake caller session) does NOT update this field at all: that is not a fact about the schedule. A disabled schedule is not run and is not stamped either.
    - `taskState` 'absent' | 'declared' | 'confirmed': **PRESENT wherever `editionCapabilities.unattendedSchedules` is true** (DVT-4490) — the normal Snowflake native-app case. It is OMITTED — not emitted as an out-of-enum empty value — only when an operator has set `DVT_UNATTENDED_SCHEDULES_ENABLED=false` on both dvt-api and dvt-engine, or on an edition where `emailSchedule` itself is false; it is not in `required` above so that omission is contract-legal. Read a missing value as "no unattended surface here", never as "no task". Artifact export schedules are unaffected and keep returning `absent` regardless. Two mechanisms can drive this value now (DVT-4794, ADR-0069 Amendment 5): a `POST .../activate` PINS this schedule under dvt's own dispatcher — a row write, no Snowflake statement — and sets `declared` immediately, with `taskOwnerRole` left null (a dispatcher-driven send has no consumer-owned task to protect); or a LEGACY consumer-owned Snowflake task (an admin's own `GET .../setup` paste, predating this change) moves `absent` -> `declared` -> `confirmed` as before, with `taskOwnerRole` trust-on-first-use pinned by its first real run. `absent` covers both "nothing has ever activated this schedule" and, for the legacy path only, "a hand-pasted task exists but has not fired yet" — dvt cannot tell those two apart because it does not own that task.
    - `taskOwnerRole` string, nullable: The Snowflake role this schedule's task actually ran as, recorded trust-on-first-use from the first run. Null until then, and always null for an artifact schedule. In the native app the run's role is unobservable (`INVOKER_ROLE()` reports the app name, DVT-4641), so it is never pinned from a run and a run is not refused on it. A scheduled report's rows are read by THIS role, not by the schedule's creator.
    - `taskLastSeenAt` string, date-time, nullable: When that task last called in. Null means never. Always null for an artifact schedule.
    - `panelId` string, nullable: When present, this schedule exports a single panel in isolation (element-grain export, DVT-861). `null` or absent means the whole dashboard is exported. Always `null` for PDF schedules (PNG-only restriction, ADR-0051 r2 §8). Visible to `dashboard:read` actors (not PII — panel ids are spec identifiers).
    - `pageId` string, nullable: When present, this schedule exports a single dashboard page (page-grain export, DVT-1193) identified by its spec slug. `null` or absent means whole-dashboard (unless `panelId` is set — the two are mutually exclusive). Visible to `dashboard:read` actors (not PII — page slugs are spec identifiers).
    - `enabled` boolean, required
    - `nextRunAt` string, date-time, nullable, required: Computed due-time, or `null` (DVT-4794, ADR-0069 Amendment 5 slice S3, D7). Always the plain, non-null due-time on a PDF/PNG (artifact) schedule — those drive the export runner's own selection query and have no `taskDriver` concept. On an `email_report` schedule the key is always present but the value depends on `taskDriver`: the real occurrence when `taskDriver` is `dispatcher` (dvt's own clock); `null` when `taskDriver` is the legacy `task` (a consumer-owned task's SCHEDULE clause is the real clock, and dvt does not read it — see `nextRunSource`); `null` when the schedule has never been activated (or was deactivated) at all. Closes DVT-4642.
    - `nextRunSource` 'dispatcher' | 'task-schedule', nullable: Pairs with `nextRunAt` on an `email_report` schedule (DVT-4794, ADR-0069 Amendment 5 slice S3, D7): `dispatcher` when `nextRunAt` is dvt's own computed occurrence; `task-schedule` when a legacy per-report task's own SCHEDULE clause is the real clock instead (render this as "per your task's SCHEDULE", never as `nextRunAt`, which is `null` in that case); `null` when the schedule has never been activated. Always `null` for a PDF/PNG (artifact) schedule.
    - `lastRunAt` string, date-time, nullable
    - `createdBy` string, uuid, required
    - `createdAt` string, date-time, required
    - `updatedAt` string, date-time, required
    - `taskDriver` 'task' | 'dispatcher': Which mechanism drives this `email_report` schedule's unattended sends (DVT-4794, ADR-0069 Amendment 5 slice S3, D1/D7): `dispatcher` for a dvt-DISPATCHED send with no consumer-owned task in the loop, `task` for the LEGACY consumer-owned Snowflake task 0106 established. Absent (not `null`) means "not yet activated under either mechanism" — a real, distinct state, not a missing value. Always absent for an artifact (PDF/PNG) schedule. Not PII — visible to any actor who can see the schedule at all, same as `panelId`/`pageId` above.
    - `activatedRevisionId` string, uuid: The dashboard revision pinned at Activate (DVT-4794, ADR-0069 Amendment 5 slice S3, D2/D4) — what the dispatcher plans and sends from, never the live spec. Present only when `taskDriver` is `dispatcher`. Not PII.
    - `activatedPageId` string, nullable: The page scope pinned at Activate. `null` means the activation covers the whole dashboard, not one page. Present only when `taskDriver` is `dispatcher`. Not PII.
    - `activatedCron` string: The cadence pinned at Activate. Present only when `taskDriver` is `dispatcher`. Compare against the live `cron` to derive "needs re-activation" (D6) — the SPA does this client-side rather than reading it off a field the server does not separately publish.
    - `activatedTimezone` string: The IANA timezone pinned at Activate. Present only when `taskDriver` is `dispatcher`.
    - `activatedBy` string, uuid: The user who activated this schedule. Present only when `taskDriver` is `dispatcher`.
    - `activatedAt` string, date-time: When this schedule was activated. Present only when `taskDriver` is `dispatcher`.
    - `activatedRecipients` string[]: The normalized (lowercased, sorted) recipient CEILING pinned at Activate (DVT-4794, ADR-0069 Amendment 5 slice S3, D2/D3) — a send goes to `recipients` ∩ `activatedRecipients`. Present only when `taskDriver` is `dispatcher`. PII, gated exactly like `recipients` below (ADR-0051 §3): populated only for `dashboard:write` actors.
    - `changedSinceActivation` boolean: True iff the dashboard's CURRENT revision has moved past `activatedRevisionId` (DVT-4794, ADR-0069 Amendment 5 slice S3, D4/D7) — re-activating would pick up the difference. Present only on an activated (`taskDriver: dispatcher`) row; absent on every other row, including a legacy `task`-driven one (which has no `activatedRevisionId` to compare).
    - `recipients` ExportRecipient[]: Populated only for `dashboard:write` actors (split read model). Absent for read-only viewers (PII protection).
      - `id` string, uuid, required
      - `scheduleId` string, uuid, required
      - `email` string, email, required
      - `isExternal` boolean, required: True when the email did not match a current org member at add time (fail-closed). Immutable after insert.
      - `status` 'active' | 'pending_approval' | 'rejected', required: `active` — will receive deliveries. `pending_approval` — awaiting owner/admin approval (external only). `rejected` — approval denied; never delivered to.
      - `approvedBy` string, uuid, nullable: The user who approved this recipient (external only).
      - `approvedAt` string, date-time, nullable
      - `createdAt` string, date-time, required
      - `updatedAt` string, date-time, required
    - `destinations` ExportDestination[]: Populated only for `dashboard:write` actors. Never includes secret material (ciphertext, wrapped_dek).
      - `id` string, uuid, required
      - `scheduleId` string, uuid, required
      - `kind` 'slack_webhook' | 'teams_workflows' | 'google_chat', required
      - `label` string, required: Friendly channel label (e.g. "#leadership").
      - `lastStatus` string, nullable: Last delivery outcome ("ok" or "errored"). Null until first run.
  - `meta` Meta, required: Response metadata.
    - `requestId` string, required: Correlates with the X-Request-Id header.
    - `total` integer: Item count, present on collection responses.
    - `nextCursor` string, nullable: Opaque keyset-pagination cursor, present on paginated responses (e.g. GET /v1/dashboards) when more rows exist beyond this page. Pass it back as the `cursor` query param to fetch the next page; absent or null on the last page.
    - `roleResolveDegraded` boolean: Snowflake mode only (DVT-2260), on GET /v1/dashboards: present and true when the caller's live Snowflake role resolution failed or was unavailable — role-shared dashboards may be missing from this page. Omitted (not merely false) whenever resolution succeeded or the role-share arm does not apply (off snowflake mode, an org admin/owner, or off the ingress listener).

## Other responses

- `400`: Malformed request.
- `401`: Missing, invalid, or expired session.
- `403`: The caller's role does not permit this action.
- `404`: Resource does not exist.
- `422`: The `timezone` value could not be resolved to an IANA zone (DVT-4321). Problem `type` is `https://docs.dvt.dev/errors/unresolvable-timezone`, and the failing value is echoed in a `timezone` extension field. 422 rather than 400 because the body itself parsed: exactly one value failed semantic resolution. Two causes share this status and the response cannot tell them apart, so it names the zone and lets the caller judge: - the name is not an IANA zone — a typo, an abbreviation like `PST`, or a Windows zone id; send a current IANA name such as `America/Los_Angeles`; or - the name IS a valid IANA zone but the server build has no zone database. That was the `FROM scratch` runtime image before `cmd/api` embedded `time/tzdata`; every named zone failed there while `UTC` (which needs no lookup) succeeded. If a well-known zone draws this response, the deployment is broken, not the request.

---

[API](https://skmtc.dev/dvt/apis/dvt-rest-api.md) · [All operations](https://skmtc.dev/dvt/apis/dvt-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dvt/apis/dvt-rest-api/revisions/00016f504165?raw)
