> ## Documentation Index
> Fetch the complete documentation index at: https://developers.workchats.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a message

> Sends a message as the bot: a direct message to a person (`to.type: user`), or a post in a group or channel the bot is a member of (`to.type: conversation`). People see `text`; `blocks` are stored and shown from a later release. Needs `messages:write`.

**Idempotency.** Every send needs an `Idempotency-Key`, unique per message and destination: `<event_id>:<destination_id>` works when one event goes to several people. Keys are kept for 24 hours. Repeating a key with the same body returns the original `201` and posts nothing; with a different body, `422 idempotency_key_reused`. After 24 hours, a repeat to the same destination still posts nothing: it returns the original `201` while the message is unchanged, and `422 idempotency_key_reused` once it was edited or deleted through this API. A key is 1 to 255 visible ASCII characters, with no spaces.

**Who can receive a DM.** People with an active or onboarding account. A deactivated or removed person is `409 user_deactivated`; a guest, or someone who blocked the App, is `403 cannot_dm_user`.

**Rate limits.** 5 sends a second per installation with bursts of 20, and 1 a second per destination with bursts of 5.



## OpenAPI

````yaml /openapi/staging.json post /v1/messages
openapi: 3.0.0
info:
  contact:
    email: support@workchats.com
    name: Workchats API Support
  description: >
    Post messages into Workchats as an installed App's bot user, and read

    the people and conversations it can reach.


    Authenticate with the bot token from the OAuth install, in the

    Authorization header as `Bearer wc_bot_…`. Every error response has

    the shape `{"ok": false, "error": {"code", "message"}, "request_id"}`.


    Lists are cursor-paginated: pass `limit` (default 200, at most 1000)

    and the previous page's `next_cursor` as `cursor`. `next_cursor` is

    null on the last page.


    ## Callbacks


    Workchats POSTs events to the App's registered callback URL, over

    HTTPS only. Answer with any 2xx within 3 seconds. Anything else,

    including a redirect, which is never followed, is retried after 1, 5

    and 30 minutes with the same event `id`, so ignore an `id` you have

    already handled. `X-Workchats-Attempt` counts the attempts from 1.


    Every event has the shape `{"id", "type", "workspace_id",

    "created_at", "data"}`:


    - `app.uninstalled`: `data` is `{"removed_by": {"id": "usr_…"} | null,
      "reason": "admin" | "app_disabled"}`. A company admin uninstalled
      the App, or Workchats disabled it (`removed_by` is null). The token
      has already stopped working. Revoking the token yourself through
      `POST /oauth/revoke` sends nothing.
    - `directory.changed`: `data` is `{"kind", "id"}`. Re-read the user
      with `GET /v1/users/{id}` or the conversation with
      `GET /v1/conversations/{id}`. Kinds: `user.created`,
      `user.updated` (name, email or title), `user.deactivated`
      (deactivated or removed), `conversation.created` (the bot was
      added), `conversation.renamed`, `conversation.archived` (groups),
      `conversation.deleted`, `app.removed_from_conversation`. User kinds
      need `users:read`; conversation kinds name only conversations the
      bot is, or was just, in. Ignore kinds you don't know.

    **Verifying a callback.** `X-Workchats-Signature` is `v1=` and the

    lower-case hex HMAC-SHA256 of `<X-Workchats-Timestamp>.<raw body>`,

    keyed with the App's signing secret. Compare in constant time, and

    refuse a timestamp more than 5 minutes old. For 24 hours after the

    secret is rotated the header carries two signatures,

    `v1=<new>,v1=<old>`: accept the request if any one matches.


    Worked example:


    - Signing secret: `whsec_docs_example`

    - X-Workchats-Timestamp: 1790593600

    - Body:
    `{"id":"evt_5f0c7a52-6d1e-4c1b-9a8e-2f4b7c3d1e90","type":"directory.changed","workspace_id":"cmp_0b7e4c1a-2d3f-4a5b-8c6d-7e8f9a0b1c2d","created_at":"2026-09-28T11:06:40Z","data":{"kind":"user.deactivated","id":"usr_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"}}`

    - X-Workchats-Signature:
    v1=42ec825e4ad44370ea02385d30a001c349640cff974ed895361b3c7a080e5339
  title: Workchats Public API
  version: '1.0'
servers:
  - url: https://public-api.staging.workchats.com
    variables: {}
security:
  - bearerAuth: []
tags:
  - description: Token checks
    name: Auth
  - description: Groups and channels the bot is a member of, and their messages
    name: Conversations
  - description: Files attached to messages the bot can read
    name: Files
  - description: Send, edit and delete the bot's messages
    name: Messages
  - description: Code exchange and token revocation
    name: OAuth
  - description: The workspace's people
    name: Users
paths:
  /v1/messages:
    post:
      tags:
        - Messages
      summary: Send a message
      description: >-
        Sends a message as the bot: a direct message to a person (`to.type:
        user`), or a post in a group or channel the bot is a member of
        (`to.type: conversation`). People see `text`; `blocks` are stored and
        shown from a later release. Needs `messages:write`.


        **Idempotency.** Every send needs an `Idempotency-Key`, unique per
        message and destination: `<event_id>:<destination_id>` works when one
        event goes to several people. Keys are kept for 24 hours. Repeating a
        key with the same body returns the original `201` and posts nothing;
        with a different body, `422 idempotency_key_reused`. After 24 hours, a
        repeat to the same destination still posts nothing: it returns the
        original `201` while the message is unchanged, and `422
        idempotency_key_reused` once it was edited or deleted through this API.
        A key is 1 to 255 visible ASCII characters, with no spaces.


        **Who can receive a DM.** People with an active or onboarding account. A
        deactivated or removed person is `409 user_deactivated`; a guest, or
        someone who blocked the App, is `403 cannot_dm_user`.


        **Rate limits.** 5 sends a second per installation with bursts of 20,
        and 1 a second per destination with bursts of 5.
      operationId: WorkchatsApiServerWeb.PublicApi.MessageController.create
      parameters:
        - description: >-
            Unique per message and destination: 1 to 255 visible ASCII
            characters, no spaces
          in: header
          name: Idempotency-Key
          required: true
          schema:
            example: evt_88231:usr_9a1
            maxLength: 255
            minLength: 1
            pattern: ^[\x21-\x7E]+$
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PublicApiMessageCreateRequest'
        description: The message
        required: true
      responses:
        '201':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiMessageResponse'
          description: >-
            The message was sent, or this is a repeat of a send with the same
            key and body
        '400':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: >-
            `missing_idempotency_key`, `invalid_blocks`, `invalid_request` (a
            bad `Idempotency-Key`, a missing or bad field, `text` over 4,000
            characters, `blocks` over 16 KB, `metadata` over 4 KB, a `thread_id`
            that is not a message in the destination) or `token_in_query`
        '401':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`not_authed`, `invalid_auth` or `token_revoked`'
        '403':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: >-
            `missing_scope` (the token lacks `messages:write`),
            `cannot_dm_user`, `not_in_conversation` (the App was removed from
            the conversation) or `not_allowed_in_conversation` (an announcement
            channel)
        '404':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: >-
            `user_not_found`, or `conversation_not_found` (no such conversation,
            or the App was never a member of it)
        '406':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`not_acceptable`: the Accept header does not allow application/json'
        '409':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`conversation_archived` or `user_deactivated`'
        '413':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`payload_too_large`: the request body is too large'
        '415':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`unsupported_media_type`: the body is not `application/json`'
        '422':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: >-
            `idempotency_key_reused`: the key was used with a different body,
            its message was edited or deleted, or a message the App did not send
            already holds it
        '429':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`rate_limited`: retry after the Retry-After header''s seconds'
        '500':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicApiError'
          description: '`internal_error`: something went wrong on our side'
      callbacks: {}
      security:
        - bearerAuth: []
components:
  schemas:
    PublicApiMessageCreateRequest:
      example:
        blocks:
          - text: New lead from Meta Lead Ads
            type: header
          - fields:
              - label: Campaign
                value: Meta Lead Ads (Paid)
            type: fields
        metadata:
          lead_id: lead_mul0oe5wo0dgol
          source: leadey
        text: 'New lead: Sarah Chen (Acme Ltd) from Meta Lead Ads'
        thread_id: null
        to:
          id: chn_c41a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
          type: conversation
        unfurl_links: false
      properties:
        blocks:
          description: >-
            Rich layout, at most 16 KB. Stored now and shown from a later
            release.
          items:
            $ref: '#/components/schemas/PublicApiBlock'
          maxItems: 50
          type: array
        metadata:
          additionalProperties: true
          description: >-
            Your own data, at most 4 KB, stored with the message. Never shown in
            the app, but not secret: do not put credentials in it.
          type: object
        text:
          description: What people see, and the push notification's text. Always send it.
          maxLength: 4000
          minLength: 1
          type: string
        thread_id:
          description: >-
            A message in the same destination to reply to. It is quoted above
            the new message.
          nullable: true
          type: string
        to:
          description: >-
            `user` with a `usr_` id sends a direct message; `conversation` with
            a `grp_` or `chn_` id from `GET /v1/conversations` posts there
          properties:
            id:
              type: string
            type:
              enum:
                - user
                - conversation
              type: string
          required:
            - type
            - id
          type: object
        unfurl_links:
          description: 'Accepted and ignored: App messages never unfurl links'
          type: boolean
      required:
        - to
        - text
      title: PublicApiMessageCreateRequest
      type: object
    PublicApiMessageResponse:
      example:
        message:
          conversation_id: dm_77a1b2c3-d4e5-4f60-8a7b-9c0d1e2f3a4b
          created_at: '2026-09-28T11:05:12Z'
          id: dmsg_4e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b
          thread_id: null
        ok: true
      properties:
        message:
          properties:
            conversation_id:
              description: >-
                `grp_` or `chn_` for a group or channel, `dm_` for a direct
                message
              type: string
            created_at:
              format: date-time
              type: string
            id:
              description: '`gmsg_` in a group or channel, `dmsg_` in a direct message'
              type: string
            thread_id:
              description: The message this one replies to
              nullable: true
              type: string
          required:
            - id
            - conversation_id
            - thread_id
            - created_at
          type: object
        ok:
          enum:
            - true
          type: boolean
      required:
        - ok
        - message
      title: PublicApiMessageResponse
      type: object
    PublicApiError:
      description: Every non-2xx response from the public API.
      example:
        error:
          code: token_revoked
          message: The token has been revoked.
        ok: false
        request_id: F7p0mJcXk3tq2x0AAAAB
      properties:
        error:
          properties:
            code:
              description: >-
                Stable, machine-readable error code. Each code always comes with
                the same HTTP status, except `client_error` and `server_error`,
                which carry the response's own 4xx or 5xx status. New codes may
                be added; treat an unknown one as a generic failure.
              type: string
              x-extensible-enum:
                - cannot_dm_user
                - client_error
                - conversation_archived
                - conversation_not_found
                - file_not_found
                - idempotency_key_reused
                - internal_error
                - invalid_auth
                - invalid_blocks
                - invalid_client
                - invalid_cursor
                - invalid_grant
                - invalid_request
                - message_not_found
                - missing_idempotency_key
                - missing_scope
                - not_acceptable
                - not_allowed_in_conversation
                - not_authed
                - not_found
                - not_in_conversation
                - payload_too_large
                - rate_limited
                - server_error
                - token_in_query
                - token_revoked
                - unsupported_grant_type
                - unsupported_media_type
                - user_deactivated
                - user_not_found
            message:
              description: Human-readable explanation
              type: string
          required:
            - code
            - message
          type: object
        ok:
          enum:
            - false
          type: boolean
        request_id:
          description: Also sent as the X-Request-Id header. Quote it to support.
          type: string
      required:
        - ok
        - error
        - request_id
      title: PublicApiError
      type: object
    PublicApiBlock:
      description: >-
        One block of a rich message layout. `header`, `section` and `context`
        take `text`; `fields` takes 1 to 10 `fields`; `actions` takes 1 to 5
        button `elements`; `divider` takes nothing. Any other field is
        `invalid_blocks`.
      properties:
        elements:
          items:
            $ref: '#/components/schemas/PublicApiButton'
          maxItems: 5
          minItems: 1
          type: array
        fields:
          items:
            properties:
              label:
                minLength: 1
                type: string
              value:
                type: string
            required:
              - label
              - value
            type: object
          maxItems: 10
          minItems: 1
          type: array
        text:
          description: >-
            For `section`: Markdown with `**bold**`, `_italic_`, `[label](url)`
            and line breaks
          minLength: 1
          type: string
        type:
          enum:
            - header
            - section
            - fields
            - actions
            - context
            - divider
          type: string
      required:
        - type
      title: PublicApiBlock
      type: object
    PublicApiButton:
      description: >-
        A link button (`url`) or an action button (`action_id`, optional
        `value`), never both. Action buttons are not shown until a later
        release.
      properties:
        action_id:
          minLength: 1
          type: string
        style:
          enum:
            - primary
            - danger
          type: string
        text:
          minLength: 1
          type: string
        type:
          enum:
            - button
          type: string
        url:
          description: An http or https link
          format: uri
          type: string
        value:
          type: string
      required:
        - type
        - text
      title: PublicApiButton
      type: object
  securitySchemes:
    bearerAuth:
      description: Bot token (`wc_bot_live_…` or `wc_bot_test_…`) from the OAuth install
      scheme: bearer
      type: http

````