---
title: "Send a broadcast"
method: POST
path: "/v1/projects/{project}/broadcasts"
tags: ["Broadcasts"]
---

# Send a broadcast

`POST /v1/projects/{project}/broadcasts`

## Request

- Base URL: `https://api.subscriby.net`
- URL: `https://api.subscriby.net/v1/projects/{project}/broadcasts`
- Auth: HTTP bearer

```bash
curl -X POST https://api.subscriby.net/v1/projects/$PROJECT_ID/broadcasts \
  -H "Authorization: Bearer $SUBSCRIBY_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "<b>New drop is live</b>\nDoors open at 19:00 UTC.",
    "audience": "customer"
  }'
```

`202`, not `200`: the send is queued and runs in the background at roughly 28 messages a second to stay inside the platform's rate limit. The response describes what was *addressed*, not what was delivered.

### Audience segments

| Segment                          | Who it addresses                                                                          |
| -------------------------------- | ----------------------------------------------------------------------------------------- |
| `all`                            | Every member reachable on the project's connector, whatever their status.                 |
| `customer`                       | Members holding a paid subscription right now.                                            |
| `trialing`                       | Members currently inside a free trial.                                                    |
| `lead`                           | Members who have used the project's bot but never subscribed.                             |
| `churned`                        | Members who were customers and are not any more.                                          |
| `expiring_soon`                  | Members whose subscription genuinely lapses within `expiring_within_days`.                |
| `cancelled_still_active`         | Members who cancelled but have not run out yet: still reachable, still winnable.          |
| `paused`                         | Members who paused their subscription rather than ending it.                              |
| `trialing_cardless`              | Members trialing without a card on file, who will not convert on their own.               |
| `all_pass_holders`               | Everyone holding a valid pass for any upcoming window.                                    |
| `all_pass_holders_not_in_queue`  | Everyone who has not tapped their invite link, across every upcoming window.              |
| `pass_holders`                   | Everyone holding a valid pass for one access window you choose (`pass_window_id`).        |
| `pass_holders_not_in_queue`      | Holders of one window you choose who have not tapped their invite link yet.               |

A member is a recipient only if they have a linked chat **on that project's connector**. Members with no chat, or whose chat belongs to a different installation, are excluded silently, which is why `recipient_estimate` is frequently well below a project's total membership.

> **An auto-renewing subscription is never `expiring_soon`.** A subscription's `ends_at` is rewritten to the new period end on every renewal, so a date inside the horizon describes an auto-renewing member's next **invoice** rather than an expiry. Counting it would place every monthly subscriber in this segment once a month. A member is counted only when their access genuinely lapses: they have cancelled, or the plan is not recurring.

### Narrowing a segment to a plan

`plan_id` **composes** with `audience` rather than replacing it:

| Request                                     | Who it addresses                         |
| ------------------------------------------- | ---------------------------------------- |
| `audience=customer` + `plan_id=<gold>`      | People paying for Gold right now.        |
| `audience=churned` + `plan_id=<gold>`       | People who held Gold and left.           |
| `audience=paused` + `plan_id=<gold>`        | People whose *paused* plan is Gold.      |
| `audience=expiring_soon` + `plan_id=<gold>` | Gold members lapsing inside the horizon. |

The plan and the state describe the **same subscription**, so a member paying for Silver who once trialled Gold is not matched by the first row. `churned` is the deliberate exception in the other direction: the subscription proving they held the plan is the one that ended, so it is not required to be live.

A `plan_id` sent with `lead` or a pass segment is **refused with `422` and `reason: plan_filter_unsupported`**, not ignored. Silently dropping it would return a recipient count for a different audience than the request described, and a caller has no way to notice.

### Message formatting

The body is reduced to the HTML subset the delivering connector accepts before sending; for the messaging connector that is `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<ins>`, `<s>`, `<strike>`, `<del>`, `<a>`, `<code>`, `<pre>`, `<blockquote>` and `<tg-spoiler>`. Anything else is **stripped, not escaped**: the platform rejects an entire message containing an unknown tag, so removing it is what keeps the message deliverable.

The 4,096-character limit is the platform's own. A longer body is refused here rather than being truncated per recipient.

### Refusals

Every `422` is refused **before** anything is queued. The underlying job re-checks the same conditions and aborts silently, which is correct for a background job and wrong for an API caller, so the endpoint rejects them up front rather than returning `202` for a send that could never leave. A segment that resolves to zero recipients is the send action's own refusal, shared with the dashboard: it is not queued.

## Path parameters

- `project` string, uuid, required

## Headers

- `Idempotency-Key` string, uuid, required

## Request body

- StoreBroadcastRequest: A message and the segment of the project's members it goes to.
  - `message` string, required: The message body, 1 to 4,096 characters, in the HTML subset the delivering connector accepts. Unknown tags are stripped rather than escaped, because the platform rejects a whole message that holds one.
  - `audience` 'all' | 'customer' | 'trialing' | 'lead' | 'churned' | 'expiring_soon' | 'cancelled_still_active' | 'paused' | 'trialing_cardless' | 'all_pass_holders' | 'all_pass_holders_not_in_queue' | 'pass_holders' | 'pass_holders_not_in_queue': Who a broadcast is addressed to. Previously a bare string that BroadcastMessageJob fed to `ProjectUserStatus::tryFrom()`, which quietly meant "anything unrecognised * broadcasts to everyone" — a typo in the audience widened the send rather than refusing it. The pass segments cannot be expressed as a status at all: they are a fact about a subscription bound to a window, not about the member. Declaration order is the order the picker offers them, so each pass segment's every-window form sits above the one that asks for a single date. The backing values of the status-derived cases match ProjectUserStatus so a broadcast queued before this enum existed still resolves.
  - `pass_window_id` string, uuid, nullable: The access window for `pass_holders` and `pass_holders_not_in_queue`; required by those two, ignored by every other segment.
  - `plan_id` string, uuid, nullable: Narrow the segment to members whose subscription is on this plan; it composes with `audience` rather than replacing it. Refused, not ignored, for `lead` and the pass segments.
  - `expiring_within_days` integer, nullable: The horizon `expiring_soon` counts within, 1 to 90; defaults to 7. Read by no other segment.

## Response `202`

202 with the segment, the verified filters, the recipient count and the ETA.

- object
  - `data` object, required: The response's payload.
    - `project_id` string, required: The project the broadcast goes out from.
    - `audience` string, required: The segment as verified.
    - `pass_window_id` string, nullable, required: The window a pass segment addresses; null for every other segment.
    - `plan_id` string, nullable, required: The plan filter as verified; null when none was sent.
    - `expiring_within_days` integer, nullable, required: The horizon `expiring_soon` used; null for every other segment.
    - `recipient_estimate` integer, required: How many members the segment resolved to when the send was queued.
    - `estimated_seconds` integer, required: Roughly how long delivery will take at the platform's rate.
    - `status` 'queued', required: Always `queued`.

## Other responses

- `400`: `IDEMPOTENCY_KEY_MISSING`: every write needs an `Idempotency-Key` header. Send a fresh UUID per distinct operation.
- `401`: `AUTHENTICATION_REQUIRED`: the request carries no bearer token, or one that is revoked, malformed, or minted for another environment (an `sbt_test_` token on production).
- `403`: `TOKEN_MISSING_ABILITY`: the token is valid but does not carry the ability this endpoint requires; `error.context.required_ability` names the one to grant. An endpoint that also checks who owns a row or which tier the account is on answers `FORBIDDEN`, `TEAM_TIER_REQUIRED` or `CONNECTOR_TIER_REQUIRED` with the same status, and says so in its own description.
- `404`: `RESOURCE_NOT_FOUND`: an id in the path names nothing the token can see. `TENANT_MISMATCH`: the project sits outside the token's `scope:project:` allow-list, or the token carries no team scope. Both answer 404 rather than 403 so that existence outside the token's scope cannot be inferred.
- `409`: `IDEMPOTENCY_KEY_REUSED`: the key was already used in the last 24 hours with a different request body.
- `422`: `VALIDATION_FAILED`: the payload broke a rule, and `error.fields` maps each offending key to its messages. A refusal from the domain, such as a plan that cannot go on sale or a member who cannot be removed, uses the same code with `error.message` saying why and no `fields`. On this endpoint: `VALIDATION_FAILED`: with `error.context.reason` of `no_connected_bot` (the project has no connector connected, so it has no way to deliver), `missing_capability` (a pass segment on a project whose plan no longer includes passes), `pass_window_required` (a single-window segment with no `pass_window_id`), `unknown_audience` (the context lists the `supported` values), `plan_filter_unsupported` (a `plan_id` with `lead` or a pass segment) or `unknown_plan` (the `plan_id` is not this project's); or with no reason when the segment holds nobody right now, in which case `error.message` says which.
- `425`: `IDEMPOTENCY_REPLAY_IN_PROGRESS`: the first request with this key is still running; retry in a few seconds and the original response is replayed.
- `429`: `RATE_LIMITED`: the token has spent its 300 requests a minute or 10,000 an hour; `Retry-After` says when the next one is accepted.

---

[API](https://skmtc.dev/memberpass/apis/subscriby-rest-api.md) · [All operations](https://skmtc.dev/memberpass/apis/subscriby-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/memberpass/apis/subscriby-rest-api/revisions/59c4df91d764?raw)
