---
title: "Add an envelope-encrypted delivery destination to a schedule."
method: POST
path: "/v1/dashboards/{id}/export-schedules/{sid}/destinations"
tags: ["exports"]
---

# Add an envelope-encrypted delivery destination to a schedule.

`POST /v1/dashboards/{id}/export-schedules/{sid}/destinations`

## Request

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

Creates an export destination for a non-email delivery channel (Slack incoming webhook
for MVP — DVT-729). Requires `dashboard:write`. **Not available on `xlsx`/`csv`
schedules (400):** those are attached to email only and never stored, so a channel
has nothing to link to.

The plaintext webhook URL is envelope-encrypted by the engine broker (AES-256-GCM,
per-row DEK, AAD bound to the destination id and org — ADR-0051 §4, ADR-0012).
Go never persists or logs the plaintext URL. Only the ciphertext is stored.

Strict URL validation: scheme must be `https`, host must be exactly
`hooks.slack.com` (no port, no userinfo, no suffix), path must be non-empty.

On its tick the export runner (DVT-727) decrypts the URL on the engine and posts a
Block Kit message to the channel (DVT-729). A duplicate (schedule_id, kind, label)
is rejected with 409 to prevent duplicate posts per tick (DVT-744). Returns the read
projection (id, scheduleId, kind, label, lastStatus); secret columns are never
serialised.

## Request body

- CreateExportDestinationRequest: Body for POST /v1/dashboards/{id}/export-schedules/{sid}/destinations. The `webhookUrl` is validated server-side per-kind (ADR-0051 §9) and envelope-encrypted before storage — it is never persisted or returned in plaintext (ADR-0051 §4, ADR-0012).
  - `kind` 'slack_webhook' | 'teams_workflows' | 'google_chat', required: Destination type. One of `slack_webhook` (Slack incoming webhook), `teams_workflows` (Microsoft Teams / Power Automate workflow webhook), or `google_chat` (Google Chat incoming webhook).
  - `label` string, required: Friendly channel label shown in the UI, e.g. "#leadership".
  - `webhookUrl` string, uri, required: Incoming-webhook URL. Host rules vary by kind (ADR-0051 §9): - `slack_webhook`: must be `https://hooks.slack.com/services/…` (no port, non-empty path). - `teams_workflows`: host must end with `.logic.azure.com` at a label boundary (e.g. `prod.westus.logic.azure.com`); port must be absent or 443; path must start with `/workflows/`. - `google_chat`: must be `https://chat.googleapis.com/v1/spaces/…` (no port). Stored encrypted (write-only) — never returned on any read path.

## Response `201`

Destination created.

- ExportDestinationEnvelope
  - `data` ExportDestination, required: Read projection of a non-email delivery channel (DVT-864 / ADR-0051 §9). Secret columns (`secret_ciphertext`, `wrapped_dek`) are write-only and NEVER returned on any read path (ADR-0051 §3, ADR-0012). Only `{id, scheduleId, kind, label, lastStatus}` is serialised.
    - `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.
- `409`: A destination with this kind and label already exists on this schedule.

---

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