---
title: "Post a Discord message now, or schedule one"
method: POST
path: "/discord/messages"
tags: ["Discord"]
---

# Post a Discord message now, or schedule one

`POST /discord/messages`

## Request

- Base URL: `https://api.reacherapp.com/public/v1`
- URL: `https://api.reacherapp.com/public/v1/discord/messages`
- Auth: API key in header `x-api-key` and API key in header `x-shop-id`

Post a message to a channel immediately, schedule it for later, or start a recurring series (e.g. a weekly announcement).

- **Now:** omit `scheduled_for` — the response is the final `sent` / `failed` record.
- **Scheduled:** set `scheduled_for`.
- **Recurring:** set `scheduled_for` (first fire) **and** `recurrence` (e.g. `{"frequency": "weekly", "end_type": "never"}`).

`Idempotency-Key` is required so a retry can't double-post. `X-Dry-Run: true` validates without posting or scheduling. Attach up to 10 images / files via `images` (each base64-encoded, ≤ 8MB) — e.g. a video-breakdown image.

## Headers

- `Idempotency-Key` string, nullable
- `X-Dry-Run` string, nullable

## Request body

- DiscordMessageCreateRequest: Post now, schedule, or start a recurring series. Omit ``scheduled_for`` (and ``recurrence``) to post immediately — the route runs the delivery pipeline inline and returns the final ``sent`` / ``failed`` record. A ``recurrence`` requires ``scheduled_for`` (the first fire).
  - `channel_id` string, required: Target Discord channel id.
  - `content` string, required: Message body (≤ 2000 chars).
  - `title` string, nullable: Optional bold header rendered above the body.
  - `mention` MentionTarget: Who the announcement pings. `role` requires an explicit role_id.
    - `type` 'none' | 'here' | 'everyone' | 'role'
    - `role_id` string, nullable
  - `scheduled_for` string, date-time, nullable: When to post (ISO 8601). Omit to post immediately.
  - `timezone` string, nullable: IANA tz for the schedule (defaults to the shop's).
  - `recurrence` Recurrence: The parsed ``recurrence`` JSONB column / API contract. Parsed ONCE at the query-layer boundary (``parse_recurrence``) so a malformed shape 400s at create time and can never blow up mid-dispatch. ``paused`` deliberately lives in its own column (the claim predicate must match the partial index literally), so extra keys are ignored rather than rejected — legacy rows carrying ``paused`` in the blob still parse.
    - `frequency` 'daily' | 'weekdays' | 'weekly' | 'biweekly' | 'monthly', required
    - `end_type` 'never' | 'on' | 'after'
    - `until` string, date, nullable
    - `occurrences` integer, nullable
  - `campaign_id` integer, nullable: Optional campaign to associate the post with.
  - `sender_user_id` string, nullable: Discord user id of a member of the linked server to post AS — the message shows their server nickname and avatar instead of the brand's (Discord still marks it APP). Omit to post as the brand. Bots and non-members are rejected with 400.
  - `images` DiscordImage[], nullable: Up to 10 images / files to attach (e.g. a video-breakdown image), each base64-encoded and ≤ 8MB decoded.
    - `filename` string, required
    - `content_type` string, required: MIME type, e.g. `image/png`. Images + PDF/CSV/Excel/Word/txt.
    - `data_base64` string, required: The file's bytes, base64-encoded. Max 8MB decoded, ≤10 files per message.

## Response `201`

Successful Response

- DiscordMessageWriteResponse: Envelope docs for the create route (runtime returns a JSONResponse).
  - `data` DiscordMessage, required: A posted or scheduled message, with its engagement once delivered. A curated projection of the internal record — pipeline internals (delivery log, retry bookkeeping, rendered body, portal author identity) stay internal.
    - `id` integer, required
    - `channel_id` string, required
    - `channel_name` string, nullable
    - `title` string, nullable
    - `content` string, required
    - `mention` MentionTarget: Who the announcement pings. `role` requires an explicit role_id.
      - `type` 'none' | 'here' | 'everyone' | 'role'
      - `role_id` string, nullable
    - `campaign_id` integer, nullable
    - `attachments` DiscordAttachment[]
      - `filename` string, required
      - `content_type` string, required
      - `size_bytes` integer, required
    - `sender` SenderIdentity: Who the announcement appears to come from. The post is always made by the Reacher webhook; `member` only overrides the webhook's display name + avatar for that one message (the APP badge stays). `brand` = the shop name, which is also what a NULL sender means on the row. On a request only `user_id` is trusted: the controller resolves it against the linked guild and takes `display_name` / `avatar_url` from Discord, so a caller cannot persist a fabricated identity.
      - `type` 'brand' | 'member'
      - `user_id` string, nullable
      - `display_name` string, nullable
      - `avatar_url` string, nullable
    - `status` string, required: `scheduled`, `sending`, `sent`, `failed`, or `canceled`.
    - `scheduled_for` string, nullable
    - `timezone` string, required
    - `recurrence` Recurrence: The parsed ``recurrence`` JSONB column / API contract. Parsed ONCE at the query-layer boundary (``parse_recurrence``) so a malformed shape 400s at create time and can never blow up mid-dispatch. ``paused`` deliberately lives in its own column (the claim predicate must match the partial index literally), so extra keys are ignored rather than rejected — legacy rows carrying ``paused`` in the blob still parse.
      - `frequency` 'daily' | 'weekdays' | 'weekly' | 'biweekly' | 'monthly', required
      - `end_type` 'never' | 'on' | 'after'
      - `until` string, date, nullable
      - `occurrences` integer, nullable
    - `paused` boolean
    - `series_id` integer, nullable: Set on an occurrence of a recurring series.
    - `occurrence_num` integer, nullable
    - `occurrences_sent` integer
    - `discord_message_id` string, nullable
    - `message_url` string, nullable
    - `sent_at` string, nullable
    - `failure_reason` string, nullable
    - `failure_code` string, nullable
    - `audience_count` integer, nullable
    - `reaction_count` integer, nullable
    - `reply_count` integer, nullable
    - `link_click_count` integer, nullable
    - `upcoming_occurrences` string[], nullable: Next fire times, on a recurring series' detail read.
    - `created_at` string, nullable, required
    - `updated_at` string, nullable, required
  - `dry_run` boolean

## Other responses

- `400`: Validation error, multi-shop key, or missing/invalid Idempotency-Key.
- `403`: API key lacks `read_write` scope (`WRITE_NOT_PERMITTED`).
- `404`: Shop has no Discord integration, or message not found in this shop.
- `409`: Idempotency conflict.
- `422`: Validation Error
- `429`: Write-tier rate limit exceeded.

## Changes

> 76 revisions in range; 1 not diffed.

- **2026-09-19** `291230805105`: 4 breaking
  - response property `data/created_at` list-of-types was widened by adding types `null` to media type `application/json` of response `201`
  - response property `data/updated_at` list-of-types was widened by adding types `null` to media type `application/json` of response `201`
  - the `data/scheduled_for/anyOf[subschema #1]/` response's property format changed from `date-time` to no format for status `201`
  - the `data/sent_at/anyOf[subschema #1]/` response's property format changed from `date-time` to no format for status `201`
- **2026-09-10** `7dad11366a32`: 2 info
  - added the new optional request property `sender_user_id`
  - added the optional property `data/sender` to the response with the `201` status
- **2026-09-05** `643422c93bd8`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/reacherapp/apis/reacher-data-api/changes/discord/messages/post.md)

---

[API](https://skmtc.dev/reacherapp/apis/reacher-data-api.md) - [All operations](https://skmtc.dev/reacherapp/apis/reacher-data-api/llms.txt) - [OpenAPI document](https://skmtc.dev/reacherapp/apis/reacher-data-api/revisions/63ce74b30b5a?raw)
