---
title: "Update a dashboard banner (platform admin)"
method: PUT
path: "/v1/admin/banners/{id}"
tags: ["Internal"]
---

# Update a dashboard banner (platform admin)

`PUT /v1/admin/banners/{id}`

Overwrites an existing banner. contentVersion is recomputed and enabledEpoch bumps only on a false->true transition, so an edit or a re-enable re-shows a dismissed banner while a no-op save does not. Same validation as create; reason is required and audited. expectedUpdatedAt is the revision the operator loaded: the write is rejected 409 when the stored banner changed after that load, so a stale save cannot clobber a concurrent edit.

## Path parameters

- `id` string, required

## Request body

- BannerUpdateRequest — Update a banner: the create fields plus expectedUpdatedAt, the optimistic lock that guards the operator's load-to-submit window. The server conditions the write on the operator-supplied expectedUpdatedAt, so a save can never silently overwrite a concurrent edit.
  - `audience` 'all' | 'orgs', required
  - `ctaLabel` string
  - `ctaUrl` string — https: or mailto: only; rejected otherwise.
  - `dismissible` boolean, required
  - `enabled` boolean, required
  - `excludePaths` string[]
  - `message` string, required
  - `name` string, required
  - `orgIds` string[] — Required and non-empty when audience is "orgs"; rejected when "all".
  - `pathPrefixes` string[] — Required and non-empty when scope is "paths" (each must start with "/"); rejected when "all".
  - `reason` string, required — Audit reason (recorded in the admin audit log, not on the row).
  - `scope` 'all' | 'paths', required
  - `tone` 'info' | 'warn' | 'danger', required
  - `expectedUpdatedAt` string, required — The updatedAt the operator loaded; the write is rejected 409 if the stored banner has changed since.

## Response `200`

Updated

- Banner — One operator-authored dashboard banner (OR-347). message is rendered as plain text downstream (never HTML); ctaUrl is validated server-side to an https:/mailto: allowlist. audience and scope are discriminators, not emptiness: audience "orgs" targets exactly orgIds, scope "paths" shows only under pathPrefixes (minus excludePaths). contentVersion is a short hash over the visible content and enabledEpoch increments only on a false->true transition, so the dashboard re-shows a dismissed banner on an edit or a re-enable but not on a no-op save.
  - `audience` 'all' | 'orgs', required — "all" targets every org; "orgs" targets exactly orgIds.
  - `contentVersion` string, required
  - `createdAt` string
  - `createdBy` string
  - `ctaLabel` string
  - `ctaUrl` string — https: or mailto: only.
  - `dismissible` boolean, required
  - `enabled` boolean, required
  - `enabledEpoch` integer, required
  - `excludePaths` string[]
  - `id` string, required
  - `message` string, required — Rendered as plain text.
  - `name` string, required — Operator-facing label; not shown to customers.
  - `orgIds` string[]
  - `pathPrefixes` string[]
  - `scope` 'all' | 'paths', required — "all" shows everywhere; "paths" shows only under pathPrefixes.
  - `tone` 'info' | 'warn' | 'danger', required
  - `updatedAt` string
  - `updatedBy` string

## Other responses

- `400` — The request is invalid
- `401` — Missing or invalid API key
- `403` — API key lacks the required scope
- `404` — Resource not found
- `409` — The request conflicts with existing state
- `503` — A required integration (e.g. payments) is not configured

## Changes

- **2026-09-04** `4a338e447d39` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/openrelay/apis/openrelay-api/changes/v1/admin/banners/:id/put.md)

---

[API](https://skmtc.dev/openrelay/apis/openrelay-api.md) · [All operations](https://skmtc.dev/openrelay/apis/openrelay-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/openrelay/openrelay-api/revisions/4a338e447d39/schema)
