Skip to content
view raw .md

Messaging

Send bot messages to users, channels, and teams. Thread replies limited to 10 per parent, one level deep. Editing a message is supported within 72h by re-sending the same `bot_message_id`.

POST/v1/send-message/API keyCLI Authsend_message_cli

Send a bot message

Sends a message to users, channels, or teams. Attach up to 25 interactive buttons per message — link buttons, signed callback URLs, modals, internal forms, commands, AI prompts, workflows, and auto-replies. The same endpoint powers thread replies (up to 10 replies per parent, one level deep), bot impersonation via `platform_settings` (Slack bot name/avatar/ephemeral) and message editing — re-POSTing with the same `bot_message_id` within 72 hours edits the original.

Request body

Targeting

NameTypeRequiredDescription
target_usersarray<string>OptionalUser UUIDs, emails, or external IDs. At least one of target_users / target_channels / target_teams is required.
target_channelsarray<string | object>OptionalChannel IDs as strings, or objects { id, channel_type?, thread? }. Set thread (parent timestamp/id) to reply inside an existing thread.
target_teamsarray<string>OptionalTeam UUIDs. Every member of the team receives the message.
skip_users_on_time_offbooleanOptionalWhen true, users flagged as OOO / on leave are skipped.

Content

NameTypeRequiredDescription
messagestringOptionalPlain text or a small HTML-safe subset. Required unless messages is set.
messagesarrayOptionalPlatform-specific payloads (advanced). Required if message is omitted.
image_urlstring (https)OptionalHTTPS image URL to attach to the message.
metadataobjectOptionalFree-form custom metadata attached to the message and echoed in callback bodies.

Threading & editing

NameTypeRequiredDescription
bot_message_idstringOptionalCustom idempotency ID. Re-POST the same value within 72 hours to edit the original message.
thread_responsesarray<{ message: string }>OptionalUp to 10 replies posted under the same parent in a single call. Only one level of nesting is allowed.

Identity & impersonation

NameTypeRequiredDescription
send_as_userstring (UUID)OptionalSlack-only. Admin-only. Post with another org member's display name and avatar. Mutually exclusive with platform_settings.bot_username, bot_icon_url, and bot_icon_emoji.

Buttons

NameTypeRequiredDescription
buttonsarray<Button>OptionalUp to 25 interactive or link buttons. Full field-by-field contract: Button object ↓. Server mints button_id as $btn/<uuid4> (client-supplied ids are overwritten).

Platform settings

NameTypeRequiredDescription
platform_settingsobjectOptionalSlack-only identity/ephemeral overrides: { bot_username?, bot_icon_url? | bot_icon_emoji?, is_ephemeral? }. Other platforms ignore this block. Mutually exclusive with send_as_user.

Button object (`buttons[]`)

Complete contract for each entry in buttons (max 25 per message). The five callbacks (callback_url, callback_form, callback_command, callback_prompt, callback_workflow) are mutually exclusive. modal_body composes with callback_url OR callback_workflow. response composes with everything. See also the page prose Interactive buttons section for the compatibility matrix, signature verification, and recipes.

NameTypeRequiredDescription
labelstring (≤ 40)RequiredText rendered on the button.
label_after_clickstring (≤ 40)OptionalReplaces label after the button is clicked (or after modal submit for modal buttons). Defaults to label.
button_type"link" | "interactive"Requiredlink opens url in the browser. interactive triggers one of the callback capabilities below.
urlstring (https)OptionalRequired when button_type = "link". The URL the user opens.
valuestring (≤ 2000)OptionalRequired when button_type = "interactive". Echoed in callback POST bodies so you know which button was clicked. Also available as {{trigger.button_value}} for workflows.
callback_urlstring (https, ≤ 2048)OptionalSigned outbound POST on click and on modal submit. Mutually exclusive with the other four callbacks. See Interactive buttons prose for HMAC verification.
modal_bodyobjectOptionalOpens a modal on click. Compose with callback_url OR callback_workflow. Input-modals require one of those (input_without_callback). Full contract: modal_body object ↓ and blocks ↓.
callback_formstring (form UUID)OptionalOpens an internal Dailybot form. UUID only (list via /v1/forms/). Mutually exclusive with other callbacks. Unknown/archived → 400 button_callback_form_not_found.
callback_commandstring (≤ 200)OptionalRuns a known Dailybot command as the clicker (e.g. help). The legacy "prompt: …" prefix is rejected — use callback_prompt. Mutually exclusive with other callbacks.
callback_promptstring (≤ 2000)OptionalFree-text AI prompt run as the clicking user (their quota/permissions). Blank or oversized → 400 button_callback_prompt_invalid. Mutually exclusive with other callbacks.
callback_workflowstring (workflow UUID)OptionalTriggers an active api_trigger workflow in the caller's org. UUID only. Unknown/inactive/cross-org → 400 button_callback_workflow_not_found. Mutually exclusive with other callbacks.
responseobjectOptionalAuto-reply shown to the clicker: { message (≤2000, required), buttons? (recursive), replace_original?, ephemeral? }. Composes with every button shape. Full contract: response object ↓.
callback_authobjectOptionalStatic transport auth for the outbound POST — only valid with callback_url. Additive to the always-on HMAC signature. Credentials are write-only. Full contract: callback_auth object ↓.
destroy_buttonbooleanOptionalDefault true. When false, the button stays clickable; every click is a fresh dispatch with a new X-Dailybot-Delivery id.
button_idstring ($btn/<uuid4>)OptionalServer-minted. Echoed in every callback body. Client-supplied values are silently overwritten — do not set this field.
platform_settingsobjectOptionalRare per-platform overrides (legacy). Prefer top-level button fields.
payloadobjectOptionalFree-form opaque payload. New interactive fields must live at the top level of the button, not inside payload.

modal_body object

Attached to an interactive Button to open a lightweight modal. Serialized size ≤ 8 KiB. Block items are detailed in [modal_body.blocks[] ↓](#send-message-modal-block).

NameTypeRequiredDescription
titlestring (≤ 200)RequiredModal title.
submit_labelstring (≤ 200)OptionalSubmit button label. Default: "Submit".
blocksarray (1..10)RequiredExactly the block types text, input, and divider (1–10). Total serialized modal_body8 KiB. Slack renders natively; other platforms collect conversationally. Full block fields: [modal_body.blocks[] ↓](#send-message-modal-block).

modal_body.blocks[] item

1–10 blocks inside modal_body.blocks. Only text, input, and divider are allowed.

NameTypeRequiredDescription
type"text" | "input" | "divider"RequiredBlock kind. Only these three values are accepted.
textstring (≤ 3000)OptionalRequired for type: "text". Display-only copy.
namestringOptionalRequired for type: "input". Must match ^[a-z][a-z0-9_]{0,62}$ and be unique within the modal. Submitted values arrive as modal_fields.<name> / {{trigger.fields.<name>}}.
labelstring (≤ 200)OptionalRequired for type: "input". Field label shown to the user.
multilinebooleanOptionalInput only. Default false.
requiredbooleanOptionalInput only. Default false.
placeholderstring (≤ 200)OptionalInput only. Optional placeholder.
max_lengthinteger (1..3000)OptionalInput only. Optional max length for the value.
defaultstringOptionalInput only. Optional prefilled value.

response object

Optional auto-reply on any Button. With callback_url, sent immediately in parallel with the outbound POST (slow-approval pattern).

NameTypeRequiredDescription
messagestring (≤ 2000)RequiredAuto-reply text shown to the clicker.
buttonsarray<Button>OptionalRecursive nested buttons using the same Button schema. Caps: nesting depth ≤ 3, ≤ 25 buttons/level, ≤ 16 KiB serialized per top-level button. Full Button fields: Button object ↑.
replace_originalbooleanOptionalReplace the source message instead of posting a new one. Default false.
ephemeralbooleanOptionalSlack-only; ignored on other platforms. Default false.

callback_auth object

Optional static transport auth on a Button. Only valid together with callback_url. Always additive to X-Dailybot-Signature.

NameTypeRequiredDescription
type"bearer" | "basic" | "custom_header"RequiredAuth kind. Exactly the fields of the chosen type — extras are rejected (button_callback_auth_invalid).
tokenstring (≤ 4096)OptionalRequired for type: "bearer". Sent as Authorization: Bearer <token>.
usernamestringOptionalRequired for type: "basic" (with password). Sent as Authorization: Basic <base64>.
passwordstringOptionalRequired for type: "basic" (with username).
header_namestring (RFC 7230 token)OptionalRequired for type: "custom_header". Denied names: host, content-length, content-type, transfer-encoding, connection, user-agent, and anything starting with x-dailybot-.
header_valuestringOptionalRequired for type: "custom_header". Sent as <header_name>: <header_value>.

Response

NameTypeRequiredDescription
bot_message_idstringRequiredThe ID you sent, or a server-generated $db/<uuid>. Reuse within 72h to edit.
thread_responsesarray<string>OptionalIDs of thread replies (only when thread_responses was sent). Each can be edited the same way as bot_message_id.

Errors

StatusWhen
400Validation error — see `code` field for specifics: button_link_and_callback_conflict, button_callback_conflict, button_callback_url_invalid, button_modal_body_invalid, button_callback_form_not_found, button_callback_command_invalid, button_callback_prompt_invalid, button_callback_workflow_not_found, button_response_invalid, button_callback_auth_invalid, buttons_count_out_of_range; plus send_as_user_conflict, send_as_user_invalid_uuid, send_as_user_not_found, invalid_thread_responses, missing_targets.
401Missing, invalid, or expired credential.
403Authenticated but not allowed — `org_admin_required` when a non-admin uses `send_as_user`; CLI role scoping returns `cli_send_message_target_not_allowed`.
429Rate-limited. Respect the `Retry-After` header.
curl -sS -X POST 'https://api.dailybot.com/v1/send-message/' \
  -H 'X-API-KEY: $DAILYBOT_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"message":"Deploy done","target_channels":["C0123456789"]}'

Try it

This is a copy-only helper — the request is not sent from your browser. Paste the command into your terminal to execute it.

  • Threads: pass `thread_responses` (max 10) to post a parent plus replies in one call. Reply to an existing message by adding `thread` inside a `target_channels` object: `{ id, thread: <parent_ts_or_id> }`.
  • Edit: re-POST with the same `bot_message_id` within 72h. Identity flags (`bot_username`, `bot_icon_*`) are ignored on edit — the platform keeps the original bot identity.
  • Impersonation: `platform_settings.bot_username` plus `bot_icon_url` OR `bot_icon_emoji` change the bot's display for that message. Slack only today; other platforms ignore the identity block.
  • Ephemeral (Slack only): set `platform_settings.is_ephemeral: true` together with `target_users` to send a private in-channel message visible only to those users.
  • CLI role scoping: admin/manager reach every user/team/public channel; team member reaches shared-team members + public channels; guest can only send to themself.
  • Cross-platform: works on Slack, Microsoft Teams, Discord, and Google Chat. Threading in DMs varies — Teams / Discord / Google Chat post flat in DMs.
  • Interactive buttons: the five callbacks (`callback_url`, `callback_form`, `callback_command`, `callback_prompt`, `callback_workflow`) are mutually exclusive on a single button (`button_callback_conflict`). `modal_body` composes with `callback_url` OR `callback_workflow` (not with internal callbacks). `response` auto-replies compose with every button shape.
  • Signed callbacks: `callback_url` buttons receive HMAC-signed POSTs with `X-Dailybot-Signature` (and `X-Dailybot-Event`, `X-Dailybot-Delivery`, `X-Dailybot-Timestamp`). Google Chat is reported as platform `hangouts` in the callback body — match on that value, not "gchat" or "googlechat".
  • See the page prose section Interactive buttons for signature-verification snippets (Node.js / Python), the `modal_body` block schema, the compatibility matrix, and callback-auth types.
POST/v1/open-conversation/API key

Open a conversation (API-Key only)

Open a conversation (API-Key only)

Errors

StatusWhen
401Missing/invalid/expired credential
403Authenticated but not permitted
429Throttled - Retry-After header set
400Validation error
curl -sS -X POST 'https://api.dailybot.com/v1/open-conversation/' -H 'X-API-KEY: $DAILYBOT_API_KEY'

Try it

This is a copy-only helper — the request is not sent from your browser. Paste the command into your terminal to execute it.

POST/v1/send-email/API key

Send an email (API-Key only)

Body: to, subject, body_html, body_text.

Errors

StatusWhen
401Missing/invalid/expired credential
403Authenticated but not permitted
429Throttled - Retry-After header set
400Validation error
curl -sS -X POST 'https://api.dailybot.com/v1/send-email/' -H 'X-API-KEY: $DAILYBOT_API_KEY'

Try it

This is a copy-only helper — the request is not sent from your browser. Paste the command into your terminal to execute it.

Button Builder

Compose interactive buttons visually and copy the exact buttons JSON, dailybot chat send command, or curl body.

Fix before sending

  • #1: Label is required.
  • #1: Interactive buttons require a value.

Generated output

[
  {
    "label": "",
    "button_type": "interactive"
  }
]

The Dailybot public API exposes messaging endpoints for delivering bot messages, emails, and private conversations across Slack, Microsoft Teams, Discord, and Google Chat. This page includes the full structured field tables for POST /v1/send-message/, POST /v1/send-email/, and POST /v1/open-conversation/ (including the complete Button / modal_body / response / callback_auth contracts), plus the send_as_user identity override and the Interactive buttons deep-dive.

Button Builder

Below the send-message reference, an interactive Button Builder lets you compose interactive buttons visually — link, plain, approve/reject, workflow, form, command, prompt, and modal buttons — and copy the exact buttons JSON array, the equivalent dailybot chat send command (using --link-button, --button, --approve-button/--reject-button with --callback-url/--callback-bearer, or --workflow-button, falling back to --buttons '<json>' for anything those flags can’t express), or a ready-to-run curl body for POST /v1/send-message/.

send_as_user identity override (Slack only)

send_as_user lets an authorized caller post a message that appears to come from another user — the message shows that user’s Slack display name and avatar instead of the bot’s identity. This is useful for automated workflows that need to present messages from the perspective of a specific team member.

Requirements:

  • Caller must be an organization admin, or have a key owned by an admin.
  • The target user (send_as_user UUID) must be active and in the same organization.
  • Only applies on Slack — other platforms ignore the field and fall back to the standard bot identity.
  • send_as_user is mutually exclusive with bot_username, bot_icon_url, and bot_icon_emoji. Combining them returns 400 with code: "send_as_user_conflict".

When send_as_user is set, Dailybot looks up the user’s connected Slack identity and uses it as the message author. The message is still sent through the Dailybot bot token — it does not use the user’s own Slack token.

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "The sprint is closed and all tickets are resolved.",
    "target_channels": ["C0123456789"],
    "send_as_user": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }'

Error codes for send_as_user:

code HTTP When
send_as_user_conflict 400 send_as_user combined with bot_username, bot_icon_url, or bot_icon_emoji
send_as_user_invalid_uuid 400 UUID format is invalid
send_as_user_not_found 400 User not found, inactive, or in a different org
org_admin_required 403 Caller is not an admin

Interactive buttons

Every message may carry up to 25 buttons rendered as native interactive elements on Slack, Microsoft Teams, Discord, and Google Chat. Two button_type values are supported:

  • link — clicking opens the url in the user’s browser.
  • interactive — clicking triggers one of four capabilities described below.

Button fields

Field Type When Notes
label string (≤ 40 chars) required Rendered on the button.
label_after_click string (≤ 40) optional Replaces label after the button is clicked. Defaults to label.
button_type "link" | "interactive" required See below.
url string (https) required when button_type = "link" The URL the user opens.
value string (≤ 2000) required when button_type = "interactive" Sent back in the callback POST body so the caller knows which button was clicked.
callback_url string (https, ≤ 2048) optional (interactive) Callback URL that receives a signed POST on click and on modal submit. Mutually exclusive with callback_form and callback_command. See Interactive callback URLs below.
modal_body object optional (interactive) Opens a modal on click. Requires callback_url if the modal has input fields. See Modal buttons below.
callback_form string (form UUID) optional (interactive) Triggers an internal Dailybot form, referenced by its UUID. Mutually exclusive with callback_url and callback_command. See Internal form triggers below.
callback_command string (≤ 200) optional (interactive) Triggers a KNOWN internal Dailybot command (e.g. help). The legacy "prompt: …" prefix is rejected — use callback_prompt. Mutually exclusive with the other callbacks.
callback_prompt string (≤ 2000) optional (interactive) Free-text AI prompt run as the clicking user. Mutually exclusive with the other callbacks. See AI prompt triggers below.
callback_workflow string (workflow UUID) optional (interactive) Triggers an internal Dailybot workflow, referenced by its UUID. Mutually exclusive with the other callbacks. See Workflow triggers below.
response object optional Auto-reply sent to the clicker: {message (≤2000, required), buttons? (recursive), replace_original?, ephemeral?}. Composes with EVERY button shape. See Auto-replies below.
callback_auth object optional (requires callback_url) Static transport auth for the outbound POST (bearer | basic | custom_header) — additive to the always-on HMAC signature. See Callback authentication below.
destroy_button bool optional (default true) When false, the button remains clickable after the first click.
platform_settings object optional Per-platform overrides (rarely needed).
payload object optional Free-form extra payload; opaque to the API. The new fields above must live at the top level, not inside payload.

Every server-side interactive button carries a server-minted button_id = "$btn/<uuid4>" that is echoed back in every callback body so the caller can uniquely identify each click. Client-supplied button_id values are silently overwritten.

Legacy note: payload.callback_config and action value fields from older integrations still work and are additive — prefer the top-level fields above for new integrations.

Compatibility matrix

✔ = allowed together, ✖ = rejected. The five callbacks are mutually exclusive with each other (button_callback_conflict).

callback_url callback_form callback_command callback_prompt callback_workflow modal_body response
callback_url
callback_form
callback_command
callback_prompt
callback_workflow
modal_body
response

A modal with input blocks requires callback_url OR callback_workflow (400 button_modal_body_invalid, detail input_without_callback).

label_after_click and destroy_button compose with everything (including response): destroy_button: false keeps the button clickable — every click is a fresh dispatch with a fresh X-Dailybot-Delivery id; for modal buttons the label_after_click flip happens at modal SUBMIT, not at open, so a cancelled modal leaves the button clickable. A plain interactive button may carry response with no callback at all (the “Skip” case).

Interactive callback URLs

An interactive button with callback_url receives an HTTP POST from Dailybot when the user clicks. This is the canonical shape for approval, feedback, and lightweight triage flows built on top of /v1/send-message/.

Request example:

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Juan wants to buy a coffee. Approve?",
    "target_users": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
    "buttons": [
      { "label": "Yes", "button_type": "interactive", "value": "approve",
        "callback_url": "https://approvals.acme.example/v1/decisions/req_9812" },
      { "label": "No",  "button_type": "interactive", "value": "deny",
        "callback_url": "https://approvals.acme.example/v1/decisions/req_9812" }
    ]
  }'

Callback POST body — Dailybot fires this when the user clicks:

POST https://approvals.acme.example/v1/decisions/req_9812
Content-Type: application/json
User-Agent: Dailybot-Chatbot/1.0
X-Dailybot-Event: button_click
X-Dailybot-Delivery: 5e2d…-uuid
X-Dailybot-Timestamp: 1782001872
X-Dailybot-Signature: t=1782001872, v1=6d9a3e…hex_hmac
{
  "event": "button_click",
  "bot_message_id": "$db/ae007b43-dde2-4fa9-bce3-71fb0975a249",
  "button":       { "id": "$btn/…", "value": "approve" },
  "user":         { "id": "…", "email": "…", "display_name": "…", "external_id": "U01ABCDEFG" },
  "organization": { "id": "…", "name": "…" },
  "platform":     "slack",
  "channel":      { "id": "D0123456", "type": "im" },
  "modal_fields": null,
  "metadata":     { "campaign": "coffee-approvals" },
  "sent_at":      "2026-07-22T14:30:00Z",
  "clicked_at":   "2026-07-22T14:31:12Z"
}

platform values. One of slack, msteams, discord, or hangouts. Google Chat is delivered as hangouts (the historical platform identifier) — match on that value, not on “gchat” or “googlechat”. sent_at is stamped when the platform message was actually delivered; clicked_at when the user clicked. metadata round-trips verbatim from your original /v1/send-message/ call.

Signature verification. Every POST carries an HMAC-SHA256 signature computed with your organization’s callback signing secret over the ASCII string "{unix_timestamp}.{raw_body}".

Node.js:

const crypto = require('crypto');
function verify(rawBody, sigHeader, secretB64) {
  const secret = Buffer.from(secretB64, 'base64url');
  const parts = Object.fromEntries(sigHeader.split(',').map(s => s.trim().split('=')));
  const ts = parseInt(parts.t, 10);
  if (Math.abs(Date.now()/1000 - ts) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex'));
}

Python:

import hmac, hashlib, base64, time
def verify(raw_body: bytes, sig_header: str, secret_b64: str) -> bool:
    secret = base64.urlsafe_b64decode(secret_b64 + '==')
    parts = dict(p.strip().split('=', 1) for p in sig_header.split(','))
    ts = int(parts['t'])
    if abs(time.time() - ts) > 300:  # 5 min replay window
        return False
    expected = hmac.new(secret, f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts['v1'])

Idempotency. X-Dailybot-Delivery is unique per click attempt. If Dailybot’s outbound POST is retried, the same delivery id is sent — use it as a dedup key.

Retries. Dailybot retries once with a 500 ms backoff on 5xx / 429 / network errors. Successful 2xx completes the fanout; any other 4xx is treated as terminal and dropped.

Add a modal_body object to an interactive button to open a lightweight modal on click. On submit, the modal’s input values are POSTed to callback_url alongside event: "modal_submit" and the value of the clicked button.

modal_body schema:

Supported block types are exactly text, input, and divider. The serialized modal_body must be ≤ 8 KiB. Input name values must match ^[a-z][a-z0-9_]{0,62}$ and be unique within the modal.

{
  "title": "string, ≤ 200 chars, required",
  "submit_label": "string, ≤ 200 chars, default 'Submit'",
  "blocks": [
    // 1..10 blocks; supported block types:
    { "type": "text", "text": "≤ 3000 chars" },
    {
      "type": "input",
      "name": "snake_case_identifier",   // required, unique per modal; regex ^[a-z][a-z0-9_]{0,62}$
      "label": "Field label, ≤ 200",     // required
      "multiline": true,                  // optional, default false
      "required": true,                   // optional, default false
      "max_length": 1000,                 // optional, ≤ 3000
      "placeholder": "≤ 200",             // optional
      "default": "prefilled value"        // optional
    },
    { "type": "divider" }
  ]
}

Request example (approve-with-comment):

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Sprint retro — how did it feel?",
    "target_teams": ["team_engineering_uuid"],
    "buttons": [
      { "label": "🚀 Great",   "button_type": "interactive", "value": "great",
        "callback_url": "https://retros.acme.example/v1/sprint-42" },
      { "label": "😐 Fine",    "button_type": "interactive", "value": "fine",
        "callback_url": "https://retros.acme.example/v1/sprint-42" },
      {
        "label": "📝 Add feedback",
        "button_type": "interactive",
        "value": "feedback",
        "callback_url": "https://retros.acme.example/v1/sprint-42",
        "modal_body": {
          "title": "Sprint feedback",
          "submit_label": "Send",
          "blocks": [
            { "type": "text", "text": "Anything on your mind about this sprint?" },
            {
              "type": "input",
              "name": "sentiment_note",
              "label": "Your feedback",
              "multiline": true,
              "required": true,
              "max_length": 1000,
              "placeholder": "One or two sentences is perfect."
            }
          ]
        }
      }
    ]
  }'

Modal-submit POST body (Dailybot posts this to callback_url):

{
  "event": "modal_submit",
  "bot_message_id": "$db/…",
  "button":       { "id": "$btn/…", "value": "feedback" },
  "user":         { "id": "…", "email": "…", "display_name": "…", "external_id": "U…" },
  "organization": { "id": "…", "name": "…" },
  "platform":     "slack",
  "channel":      { "id": "C…", "type": "channel" },
  "modal_fields": {
    "sentiment_note": "Retro was great, ship-per-day cadence is working."
  },
  "metadata":     { },
  "sent_at":      "2026-07-22T14:30:00Z",
  "clicked_at":   "2026-07-22T14:31:12Z"
}

Same headers / signature as button_click; only event and modal_fields differ.

Display-only modals (blocks contain only text and divider, no input) are allowed without callback_url — clicking Submit just closes the modal and no POST fires.

Internal form triggers

An interactive button with callback_form opens a Dailybot form on click. No external service is called; the caller’s callback_url is ignored (and rejected if both are set).

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Need to request access to a system?",
    "target_channels": ["C0AF0FBT23D"],
    "buttons": [
      { "label": "Open access-request form",
        "button_type": "interactive",
        "value": "open_access_form",
        "callback_form": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
    ]
  }'

callback_form must be the UUID of a Dailybot form in the caller’s organization (list your forms via /v1/forms/ to find it). Names and slugs are rejected — form names are mutable and ambiguous. Unknown, archived, or not-accessible forms → 400 button_callback_form_not_found. The form itself is filled by the user through the existing forms flow — see /v1/forms/ for how to read the resulting responses.

Internal command triggers

An interactive button with callback_command runs a KNOWN internal Dailybot command on click (e.g. help, kudos, checkin). Max 200 characters.

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Need help getting started?",
    "target_users": ["…"],
    "buttons": [
      { "label": "Open help",
        "button_type": "interactive",
        "value": "help",
        "callback_command": "help" }
    ]
  }'

No more prompt: prefix. The legacy "prompt: <text>" convention has been removed. A callback_command starting with prompt: (case-insensitive) is rejected with 400 button_callback_command_invalid — use the dedicated callback_prompt field instead.

Attribution. The command runs under the clicking user’s identity (role scope, usage attribution) — not the sender’s. Callers cannot use callback_command to make another user run a command they aren’t allowed to run.

Unknown commands (not in the built-in commands list) fail softly with a localized “That command isn’t available” reply to the clicker — no error to the sender.

AI prompt triggers

An interactive button with callback_prompt sends a free-text prompt (≤ 2000 chars) to the Dailybot AI assistant on click, attributed to the clicking user (their AI quota, their permissions).

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Curious about last week?",
    "target_users": ["…"],
    "buttons": [
      { "label": "Ask the assistant",
        "button_type": "interactive",
        "value": "ask_ai",
        "callback_prompt": "Summarize incidents from the past 7 days." }
    ]
  }'

Blank or oversized prompts → 400 button_callback_prompt_invalid.

Workflow triggers

An interactive button with callback_workflow triggers an internal Dailybot workflow on click, attributed to the clicking user. callback_workflow must be the UUID of a workflow in the caller’s organization — names/slugs are rejected. The workflow must exist, belong to the organization, and be active; otherwise 400 button_callback_workflow_not_found.

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Deploy is ready.",
    "target_users": ["…"],
    "buttons": [
      { "label": "Run release workflow",
        "button_type": "interactive",
        "value": "run_release",
        "callback_workflow": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }
    ]
  }'

No external POST fires and no signing material is involved — the trigger is fully internal.

Trigger variables. Steps of the triggered workflow can reference the click context through the {{trigger.*}} namespace: {{trigger.source}} (api | button_click | modal_submit), {{trigger.button_value}}, {{trigger.button_id}}, {{trigger.fields.<name>}} (modal inputs), {{trigger.clicked_at}}, {{trigger.body.*}} (raw payload), {{trigger.user.*}} (uuid, full_name, first_name, email, role of the clicker) and {{trigger.triggered_by_user_uuid}}. For plain value-branching, point several buttons at the SAME workflow with different values and branch on {{trigger.button_value}}. See /developers/api/workflows for the full trigger-variable reference.

modal_body composes with callback_workflow: the click opens the modal, and on submit the field values are delivered to the workflow as {{trigger.fields.<input.name>}} — no external server involved.

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Report an incident:",
    "target_users": ["…"],
    "buttons": [
      { "label": "Report", "button_type": "interactive", "value": "report",
        "callback_workflow": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "modal_body": {
          "title": "New incident",
          "blocks": [
            { "type": "input", "name": "summary", "label": "What happened?",
              "multiline": true, "required": true }
          ]
        } }
    ]
  }'

The workflow (trigger type api_trigger) can then use {{trigger.fields.summary}} in any step — pre-fill a form answer, compose a message, feed an AI prompt. Modal-to-workflow buttons carry no signing secret (internal route). A modal with input blocks and NEITHER callback_url nor callback_workflow is rejected (input_without_callback).

Auto-replies

Any button (including a plain interactive button with no callback) may carry a response object — an automatic reply shown to the clicker:

{
  "message": "string — required, ≤ 2000 chars",
  "buttons": [],
  "replace_original": false,
  "ephemeral": false
}

The “Skip” case — a button whose only job is to acknowledge:

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Time for your weekly reflection!",
    "target_users": ["…"],
    "buttons": [
      { "label": "Start", "button_type": "interactive", "value": "start",
        "callback_form": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" },
      { "label": "Skip this week", "button_type": "interactive", "value": "skip",
        "response": { "message": "No problem — see you next week! 👋" } }
    ]
  }'

Timing. With callback_url, the response is sent immediately on click, in parallel with the outbound POST — it is instant user feedback (“Processing your approval…”), never gated on the dispatch outcome. This is the canonical slow-approval pattern: acknowledge instantly via response, then have your server follow up on its own time with a new POST /v1/send-message/ or by editing the original message within the 72h bot_message_id window. With modal_body, the response is sent after modal SUBMIT. With the internal callbacks (callback_form / callback_command / callback_prompt / callback_workflow), the response is sent BEFORE handing off.

replace_original — replace the source message instead of posting a new one. ephemeral — Slack-only; ignored elsewhere.

Recursive buttons. response.buttons uses the same Button schema and is validated recursively: max nesting depth 3, ≤ 25 buttons per level, server-minted button_id at every depth, and a total serialized size cap of 16 KiB per top-level button. Violations → 400 button_response_invalid with detail one of response_message_required, response_message_too_long, nesting_too_deep, too_many_buttons, button_too_large.

Callback authentication

Every callback POST is ALWAYS signed with the HMAC X-Dailybot-Signature header (see Callback signing secret). If your endpoint additionally requires static credentials — an API gateway token, basic auth, a fixed header — attach a callback_auth object. It is only valid together with callback_url.

curl -X POST "https://api.dailybot.com/v1/send-message/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Approve the vendor payment?",
    "target_users": ["…"],
    "buttons": [
      { "label": "Approve", "button_type": "interactive", "value": "approve",
        "callback_url": "https://approvals.acme.example/v1/req_77",
        "callback_auth": { "type": "bearer", "token": "acme-gw-token-…" } }
    ]
  }'

Types (exactly the fields of the chosen type — extras are rejected):

type Fields Sent as
bearer token (≤ 4096) Authorization: Bearer <token>
basic username, password Authorization: Basic <base64>
custom_header header_name, header_value <header_name>: <header_value>

header_name must be a valid RFC 7230 header token and may not be one of host, content-length, content-type, transfer-encoding, connection, user-agent, or anything starting with x-dailybot-. Violations → 400 button_callback_auth_invalid.

Credentials are write-only: they are never returned by any read API and never appear in logs. The HMAC signature remains mandatory — verify it even when you also require static auth.

Interactive button error codes

All are HTTP 400. Response shape follows the existing envelope ({ "detail": "…", "code": "…" }).

code When
button_link_and_callback_conflict A link button was combined with value, modal_body, response, callback_auth, or any callback field.
button_callback_conflict An interactive button combines more than one of callback_url, callback_form, callback_command, callback_prompt, callback_workflow — or combines modal_body with an internal callback.
button_callback_url_invalid callback_url is missing scheme, non-https, malformed, longer than 2048 chars, or resolves to a private / loopback / IMDS range.
button_modal_body_invalid modal_body failed structural validation — wrong block type, ≥ 11 blocks, missing / duplicate name, oversized field, or a modal with inputs but no callback_url / callback_workflow (input_without_callback).
button_callback_form_not_found callback_form does not resolve to a form in the caller’s organization.
button_callback_command_invalid callback_command is longer than 200 chars or uses the removed prompt: prefix (use callback_prompt).
button_callback_prompt_invalid callback_prompt is blank or longer than 2000 chars.
button_callback_workflow_not_found callback_workflow is not a UUID or does not resolve to an active workflow in the caller’s organization.
button_response_invalid response failed validation — see detail: response_message_required, response_message_too_long, nesting_too_deep, too_many_buttons, button_too_large.
button_callback_auth_invalid callback_auth without callback_url, unknown type, wrong/extra fields, or a denied/invalid header_name.

The existing button-related behaviors (buttons.length cap of 25, send_as_user_* errors, invalid_thread_responses, etc.) are unchanged.

Callback signing secret

Every organization has a single callback signing secret used for HMAC. It is auto-generated the first time a message with callback_url is sent. The secret is never returned by the public API; obtain it from your organization’s admin (a rotation / retrieval endpoint is on the roadmap). Keep it in your service’s secret store; treat it like an OAuth client secret.

See it in action

Automation recipes built on the send-message API, the CLI, and the agent skill — each with copy-paste curl, CLI, and agent-prompt examples.