---
title: "Send this email-report schedule now, on the caller's own session."
method: POST
path: "/v1/dashboards/{id}/email-schedules/{sid}/run"
tags: ["dashboards"]
---

# Send this email-report schedule now, on the caller's own session.

`POST /v1/dashboards/{id}/email-schedules/{sid}/run`

## Request

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

Sends THIS schedule's report immediately (DVT-4266), using its stored recipients,
page scope and subject. Requires `dashboard:write`, and — because it delegates to
the very same handler as `POST /v1/dashboards/{id}/email` — also `data:query` +
`data:export` and a live Snowflake caller session, including that route's pinned-
query-role behavior: on a dashboard with a pinned query role, the two email CALLs
run under that pinned role instead of the caller's own (see that route's
description; DVT-4345). It can therefore never egress
anything the caller could not have emailed by hand.

> **THIS ROUTE ANSWERS `404` `feature-disabled` ONLY WHEN THE SURFACE IS
> OFF.** Every verb under `/v1/dashboards/{id}/email-schedules` shares one
> gate — create, list, get, patch, delete, run and setup — the
> `editionCapabilities.unattendedSchedules` cell. DVT-4490 ships that cell
> TRUE wherever `emailSchedule` is true, the normal Snowflake native-app
> case, so a shipped install answers normally here, not 404. When the
> refusal does fire it precedes the path parse and the authorization load,
> so it is identical for a real schedule id and an invented one.
>
> 404 survives for exactly two operator-facing reasons:
> `DVT_UNATTENDED_SCHEDULES_ENABLED=false` set on BOTH dvt-api and
> dvt-engine (an explicit opt-out, not the shipped default), or an edition
> where `emailSchedule` itself is false (gallery/enterprise — the routes
> are not even mounted there). **Stored rows are unaffected either way** —
> this is a switch, not a migration.
>
> `POST /v1/dashboards/{id}/email` ("Email me this report now") remains the
> on-demand alternative regardless of this cell. Clients must gate every
> schedule affordance on the capability cell rather than assume a fixed
> answer from this route's existence. The paragraph below describes the
> behavior every non-disabled install returns.

> **Behind the cell: schedules fire on their own once a Snowflake admin creates
> their task.**
> `GET .../{sid}/setup` returns the exact statements. Until they are run, the
> schedule is stored and previewed but nothing arrives; `POST .../run` always
> sends immediately on the caller's own live session. **No ticker runs, in any
> edition** — an unattended send is driven by a CONSUMER-OWNED Snowflake task,
> which is what gives it a real identity to read the data as without dvt holding
> a standing `IMPERSONATE ON USER` grant (ADR-0069, DVT-4300).

The send a user triggers here is EXACTLY the send the task makes, chart images
included either way — the render is server-side, from the rows dvt already
holds, under the same limits: at most 12 chart images per report (charts past the
email's render time budget or its body budget are noted in place), and a chart
that fails falls back to its "view in dvt" note instead. Two differences are real and worth stating rather than
implying. First, row provenance. For a QUERY-BOUND panel, an unattended chart is drawn from the rows
the task staged for that run, capped per panel, where a `POST .../run` chart
re-queries live at full fidelity. For a panel carrying its own inline
`data.rows` there is no re-query on EITHER path — both render those rows, and
the only row-provenance difference is the unattended path's per-panel row cap;
dvt never asks the task to re-read such a panel and stores no snapshot of a
query result. Second, 3D/WebGL chart types (`chart:*3d`, `chart:*-gl`,
`chart:surface`) always show their note on the unattended path, because a
headless render context has no reliable WebGL. Every status code and body below is the email
route's verbatim — including its 409 `email-not-configured`, 413
`email-too-large`, 502 `report-unavailable`, 504 `report-timed-out` and 504
`email-send-unconfirmed` — rather than a second vocabulary for the same outcomes.

Side effects: `lastOutcome` is stamped — with the same outcome string the
`dashboard.email` audit row records whenever the send reached an audited
outcome — and that audit row additionally carries `meta.schedule_id` (an
ADDITIVE key — the frozen 10-key meta and the six frozen outcome spellings are
unchanged) so scheduled sends are separable from interactive ones. `lastRunAt`
is NOT moved by Run now: it records scheduled (unattended) fires only. A
caller-scoped refusal (401/403/404) leaves `lastOutcome` UNCHANGED: those say
the calling principal may not send, not that the schedule cannot, and the
schedules list shows `lastOutcome` to every viewer. A 400/409/413/5xx after the
schedule is resolved stamps `not-sent-not-attempted` unless the send path
audited an outcome; a disabled schedule (409) is not run and is not stamped.

## Response `202`

Email accepted and sent.

- object
  - `data` EmailDashboardResponse, required: Body for the 202 response of POST /v1/dashboards/{id}/email.
    - `sent` boolean, required: Always true on a 202 (any failure is an error response instead).
    - `recipients` string[], required: The normalized (trimmed, deduped) recipient list actually sent to.
    - `subject` string, required: The subject line actually used (after default/truncation).
    - `bytes` integer, required: Size in bytes of the rendered HTML body actually sent.
    - `panelsRendered` integer, required: Count of panels successfully rendered into the email — data-bound panels whose query returned, plus chart panels whose inline PNG was rendered (DVT-4264).
    - `panelsFailed` integer, required: Count of panels rendered as a note instead of their content, without failing the whole email: a data-bound panel that failed to load ("could not load"), or a chart panel whose image could not be rendered (DVT-4264).
    - `panelsOmitted` integer, required: Count of panels deliberately left out of the email and replaced with a note: data-bound panels past the per-email panel cap that were never queried, chart panels past the per-email chart cap, and chart images that would have pushed the body over its byte budget ("Chart omitted — too large for email", DVT-4264). Each carries an open-in-dvt link.
    - `integration` string, required: The resolved NOTIFICATION INTEGRATION name the email was sent through. On an install using the opt-in SMTP relay (native app 1.1.1+), this is the literal `smtp_relay`.
    - `paramsApplied` string[]: The `params` keys that actually NARROWED a panel this mail contains, sorted (DVT-4468). It is the same set the mail's "Filtered:" header names and the audit row's `params_applied` records. ABSENT and EMPTY mean different things and clients must not collapse them: * **Absent** — this send carried no `params` at all, OR this API predates the field. A client that DID send `params` and gets no `paramsApplied` back is talking to an API that ignored them: the mail is the report's DEFAULT view and the user should be told so. * **`[]`** — `params` were accepted and narrowed no panel in this mail. An ordinary outcome, not a fault: a key declared only by a filter panel's own options query, or only by a panel dropped past the per-email data or chart cap, validates and then applies to nothing the recipient sees. * **Populated** — these filters narrowed a panel in the mail. A key that FAILED to load still counts as applied: the panel was queried with it, and the placeholder in its place is part of a filtered report.
  - `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.
- `409`: The schedule is disabled, or has no recipients, or email is not usable for this app (not configured, or the configured NOTIFICATION INTEGRATION is not authorized to the application), or the SMTP relay is opted in but not ready (`https://docs.dvt.dev/errors/email-relay-not-ready`; DVT-5057).
- `413`: The rendered report exceeds the email byte budget even with every table omitted (`https://docs.dvt.dev/errors/email-too-large`), or, on the SMTP relay transport, the MIME message exceeded the relay's 5 MiB cap (`https://docs.dvt.dev/errors/email-relay`; DVT-5057).
- `422`: SMTP relay transport only (`https://docs.dvt.dev/errors/email-relay`, with a `suggestion`): a recipient address is not supported, the relay allowlist is empty or excludes a recipient, there are more than 50 recipients, or the relay refuses the message headers. Nothing was sent (DVT-5057).
- `429`: SMTP relay transport only: the relay's send limit was reached (`https://docs.dvt.dev/errors/email-relay`; `suggestion` says to wait and retry). Nothing was sent (DVT-5057).
- `502`: Every data-bound panel failed to load for a non-timeout reason, so nothing was sent (`https://docs.dvt.dev/errors/report-unavailable`) — except when the reason is that the caller's Snowflake session could not be opened, which is a session-layer failure that happened before any panel query ran and whose `detail` says so rather than blaming the dashboard's queries (DVT-4345) — or the analytics engine could not be reached (`https://docs.dvt.dev/errors/engine-unavailable`). Separately, when it is the integration lookup's own Snowflake login that Snowflake refuses — not a panel query — the Problem type is `https://docs.dvt.dev/errors/caller-connect-refused` directly: no session was established and the caller's SQL was never reached, so the remediation is a fresh dvt session rather than the engine logs (DVT-4345). On the SMTP relay transport a 502 (`https://docs.dvt.dev/errors/email-relay`, with a `suggestion`) means the customer's mail server refused or could not deliver, including a PARTIAL send (`detail` "accepted N of M recipients") — do not retry blindly, a retry re-sends to the recipients who already received it (DVT-5057).
- `504`: No data-bound panel loaded before the panel budget expired, or too little of the request window (80s, measured from arrival) was left to start the send, so nothing was sent and a retry is safe (`https://docs.dvt.dev/errors/report-timed-out`), or the send did not confirm in time (`https://docs.dvt.dev/errors/email-send-unconfirmed`) — the mail MAY still be delivered; check inboxes before retrying.

---

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