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

# Get an export schedule.

`GET /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

Returns a single export schedule. Requires `dashboard:read`. Recipients and destinations
are only included for `dashboard:write` actors (split read model — ADR-0051 §3).

## Response `200`

The export 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

- `401`: Missing, invalid, or expired session.
- `404`: Resource does not exist.

---

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