---
title: "Edit an email-report schedule (Snowflake native app only)."
method: PATCH
path: "/v1/dashboards/{id}/email-schedules/{sid}"
tags: ["dashboards"]
---

# Edit an email-report schedule (Snowflake native app only).

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

## Request

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

Partial update (merge patch), requires `dashboard:write` (DVT-4266). Editable:
`title`, `cron` or `preset`, `timezone`, `pageId`, `subject`, `recipients` and
`enabled`. Changing the cadence or timezone recomputes `nextRunAt` atomically.
`recipients`, when supplied, REPLACES the whole set.

> **THIS ROUTE ANSWERS `404` `feature-disabled` ONLY WHEN THE SURFACE IS
> OFF,** like every other verb under `/v1/dashboards/{id}/email-schedules`:
> all seven share the `editionCapabilities.unattendedSchedules` cell, and
> DVT-4490 ships it TRUE wherever `emailSchedule` is true — the normal
> Snowflake native-app case, answering normally here, not 404. 404 survives
> only with `DVT_UNATTENDED_SCHEDULES_ENABLED=false` on both dvt-api and
> dvt-engine, or on an edition where `emailSchedule` itself is false. When it
> does fire it precedes the path parse, so it is identical for a real
> schedule id and an invented one. Stored rows are unaffected either way.
> `POST /v1/dashboards/{id}/email` ("Email me this report now") remains the
> on-demand alternative regardless. Everything below describes the behavior
> every non-disabled install returns.

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. A `cron`/`preset` change is
also **422** `cadence-too-fine` (DVT-4794, ADR-0069 Amendment 5 slice S3, D5) when the
new cadence fires more often than every 15 minutes; a PATCH that does not touch
`cron`/`preset` is never refused for cadence, even on a row whose stored cron predates
this floor.

On an already-ACTIVATED schedule (`activatedAt` non-null), a `recipients`/`pageId`/
`cron`/`timezone`/`enabled` change is compared against the PINNED terms Activate
recorded, not against the row's own live value (DVT-4794, ADR-0069 Amendment 5 slice
S3, D3): adding a recipient not already pinned, changing the page scope (including
to/from "every page"), changing the cadence, or turning `enabled` back on all WIDEN
what the dispatcher will send. Removing a recipient, or any other field, never widens.
A widening PATCH is applied only when the actor holds `schedule:activate` (D8) AND
passes a live-admin recheck run inline on this same request; when it does, the
widened pin field(s) move to the new value(s) in the SAME transaction. When it does
not, the PATCH still applies but the row drops to fully unactivated in that same
transaction, and the response's `activationDropped` names why.

A `pageId` change on an already-activated row is validated against the PINNED
revision (`activatedRevisionId`), not the dashboard's live spec: a page added to
the dashboard after this schedule was activated is **422**
`page-not-in-activated-revision`, regardless of which branch above the PATCH would
otherwise take — re-activate first to pin the revision that has it.

## Request body

- PatchEmailScheduleRequest: Body for PATCH /v1/dashboards/{id}/email-schedules/{sid} (DVT-4266). Every field is optional; an absent field means no change. Supplying `cron` or `preset` (or `timezone`) recomputes `nextRunAt` atomically.
  - `title` string, nullable: A string sets the label; `null` clears it; absent leaves it alone.
  - `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.
  - `pageId` string, nullable: A string re-scopes the report to that page slug; `null` clears the scope (email every non-hidden page); absent leaves it alone.
  - `subject` string: Replaces the stored subject; an empty string clears it.
  - `recipients` string[]: REPLACES the schedule's recipient set when present (set semantics). Absent leaves the existing recipients untouched.
  - `enabled` boolean: Enable or disable the schedule. Disabling is honoured by "Run now" (409) and would be honoured by a future scheduler; it does not currently stop anything automatic, because nothing automatic exists yet (see the route description).

## Response `200`

Updated schedule, plus `activationDropped` when a widening edit failed the activation check.

- object
  - `data` PatchEmailScheduleResult, required: Body of the 200 response from `PATCH /v1/dashboards/{id}/email-schedules/{sid}` (DVT-4794, ADR-0069 Amendment 5 slice S3): the updated schedule, plus `activationDropped` when a widening edit failed the activation check.
    - `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.
    - `activationDropped` object: Present only when this PATCH WIDENED an already-activated schedule's pinned recipients/page/cadence/enabled (DVT-4794, ADR-0069 Amendment 5 slice S3, D3) and the actor did not both hold `schedule:activate` and pass a live-admin recheck run inline on this same request. The edit still applied — this is a 200, not a refusal — but in the SAME transaction the row dropped to fully unactivated: driver cleared, every `activated*` field cleared, `taskState` back to `absent`. Absent on every narrowing/neutral PATCH and on any PATCH to a row that was never activated.
      - `reason` 'widening-needs-schedule-activate', required
      - `fields` string[], required: Which pinned dimension(s) this PATCH would have widened.
  - `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.
- `413`: The request body exceeds 64 KiB.
- `422`: Either `timezone` could not be resolved to an IANA zone (`unresolvable-timezone`, DVT-4321), a `cron`/`preset` change fires more often than every 15 minutes (`cadence-too-fine`, DVT-4794, ADR-0069 Amendment 5 slice S3, D5) — a `blocker` extension of `cadence-too-fine` distinguishes it from the timezone case — or `pageId` names a page absent from the row's pinned revision (`page-not-in-activated-revision`, DVT-4794 r1 F2).

---

[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)
