---
title: "Create a dashboard banner (platform admin)"
method: POST
path: "/v1/admin/banners"
tags: ["Internal"]
---

# Create a dashboard banner (platform admin)

`POST /v1/admin/banners`

Mints a new banner (server-assigned id). audience/scope are consistency- checked discriminators (audience "orgs" requires orgIds and rejects them when "all"; same for scope/pathPrefixes), message is 1..500 chars, and ctaUrl must be https: or mailto:. reason is required and goes to the audit log. Refused past a 50-row cap.

## Request body

- BannerWriteRequest — Create a banner. reason is required and goes to the admin audit log, not onto the banner row (a stored reason invites reading a stale one for the previous change). The update request is BannerUpdateRequest: the same fields plus the required expectedUpdatedAt optimistic lock.
  - `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

## Response `200`

Created

- 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
- `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/post.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)
