# Broadcast an in-app notification and/or email

`POST /v1/admin/notifications/broadcast`

URL: https://docs.spacerun.co/docs/platform/reference/notifications/broadcastNotification

- **Operation ID:** `broadcastNotification`
- **Tags:** Notifications
- **Base URL:** `http://localhost:8787`

Sends to every user matching `segment` (same filters as the users list; empty segment = every active user; `status` filter overrides the active-only default). At least one of `notification` or `email` is required. An in-app notification to an empty segment is a single broadcast row that also reaches future users; otherwise it is created per recipient. The in-app channel needs `announcements.write` and the email channel `email.send`: the route admits either permission, and a caller lacking the permission for a requested channel receives 403 with the whole request rolled back. Both channels audit inside the database functions. A dashboard event is published afterwards.

**Workflow:** [Integration guide](https://docs.spacerun.co/docs/platform/guides/operations-and-audit).

**Authentication:** staff Bearer JWT with permission `announcements.write` or `email.send`.

## Authentication

- **Auth:** staff Bearer JWT (`Authorization: Bearer <access_token>`) with the permissions below.
- **Required permissions:** `announcements.write`, `email.send`

## Request body

Required: yes.

Content type: `application/json`

Schema: `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `segment` | `object` | no | Default: `{}` |
| `segment.q` | `string` | no | Min length: 1. Max length: 200 |
| `segment.status` | `string` | no | One of: `"active"`, `"banned"`, `"deleted"` |
| `segment.tier` | `string` | no | Min length: 1. Max length: 32 |
| `segment.role` | `string` | no | Min length: 1. Max length: 48 |
| `segment.provider` | `string` | no | Min length: 1. Max length: 32 |
| `segment.has_subscription` | `boolean` | no | - |
| `segment.created_from` | `string<date-time>` | no | - |
| `segment.created_to` | `string<date-time>` | no | - |
| `segment.last_seen_from` | `string<date-time>` | no | - |
| `segment.last_seen_to` | `string<date-time>` | no | - |
| `notification` | `object` | no | - |
| `notification.kind` | `string` | no | One of: `"info"`, `"success"`, `"warning"`, `"billing"`, `"social"`, `"system"`. Default: `"info"` |
| `notification.title` | `string` | yes | Min length: 1. Max length: 200 |
| `notification.body` | `string` | no | Max length: 4000 |
| `notification.action_url` | `string` | no | Max length: 2000 |
| `notification.starts_at` | `string<date-time>` | no | - |
| `notification.ends_at` | `string<date-time>` | no | - |
| `email` | `object` | no | - |
| `email.template_key` | `string` | yes | Min length: 1. Max length: 100 |
| `email.vars` | `object` | no | Default: `{}` |

## Responses

### 200

Broadcast accepted.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `recipients` | `integer \| null` | yes | Resolved recipient count; null for an in-app broadcast to everyone. |
| `notification` | `object \| null` | yes | Null when no in-app channel was requested. |
| `notification.created` | `integer` | yes | - |
| `email` | `object \| null` | yes | Null when no email channel was requested. |
| `email.queued` | `integer` | yes | - |

## Errors

### 400

Request validation failed.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ValidationErrorBody)`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | `boolean` | yes | One of: `false` |
| `error` | `object` | yes | - |
| `error.name` | `string` | yes | One of: `"ZodError"` |
| `error.issues` | `object[]` | yes | - |
| `error.issues[].code` | `string` | yes | - |
| `error.issues[].path` | `(string \| number)[]` | yes | - |
| `error.issues[].message` | `string` | yes | - |

**Request validation**

```json
{
  "success": false,
  "error": {
    "name": "ZodError",
    "issues": [
      {
        "code": "custom",
        "path": [
          "field"
        ],
        "message": "Invalid value"
      }
    ]
  }
}
```

### 401

Missing, malformed, expired or wrong-audience Bearer token (`missing_token`, `invalid_token`, `invalid_audience`).

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | `string` | yes | Stable machine-readable error code, e.g. `forbidden`, `not_found`. |
| `details` | `any` | no | Optional structured context. For 403 it is `{ perm }`. |

**Error response**

```json
{
  "error": "missing_token"
}
```

### 403

Authenticated but lacking the required permission. `details.perm` lists the accepted permission keys.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

**Error response**

```json
{
  "error": "forbidden",
  "details": {
    "perm": "announcements.write | email.send"
  }
}
```

### 404

Not found (e.g. unknown email template).

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

### 422

`empty_segment`: no users match; `segment_too_large`: more than 10000 matches; `invalid_request`: rejected by the database function (e.g. template has no published version).

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

**Error response**

```json
{
  "error": "empty_segment"
}
```

### 500

Unexpected server error (`internal`). No details are returned.

- Header `x-request-id`: Request identifier; quote it when reporting an error.
Content type: `application/json`

Schema: `object (ErrorBody)`, fields as described above.

**Error response**

```json
{
  "error": "internal"
}
```

## Examples

### curl

```bash
curl -X POST 'http://localhost:8787/v1/admin/notifications/broadcast' \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"segment":{},"notification":{"kind":"info","title":"example","body":"example","action_url":"example","starts_at":"2026-01-15T12:00:00.000Z","ends_at":"2026-01-15T12:00:00.000Z"},"email":{"template_key":"example","vars":{}}}'
```

### TypeScript

```ts
const accessToken = process.env.ACCESS_TOKEN; // user JWT from your sign-in flow

const response = await fetch("http://localhost:8787/v1/admin/notifications/broadcast", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "segment": {},
    "notification": {
      "kind": "info",
      "title": "example",
      "body": "example",
      "action_url": "example",
      "starts_at": "2026-01-15T12:00:00.000Z",
      "ends_at": "2026-01-15T12:00:00.000Z"
    },
    "email": {
      "template_key": "example",
      "vars": {}
    }
  }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
const data = response.status === 204 || response.status === 205
  ? undefined
  : response.headers.get("content-type")?.includes("json")
    ? await response.json()
    : await response.text();
```
