Skip to content
Academy Menu

The send-message API, end to end: buttons, callbacks, and modals

A full implementation walkthrough of POST /v1/send-message/ — request anatomy, threads, identity, all five callback types, modal_body, error codes, and the CLI flags that drive them.

deep-diveDeveloperOps11 min read

POST /v1/send-message/ is one endpoint that does five jobs: it sends a message, threads a report, impersonates an identity, edits a previous send, and — the part most teams under-use — turns the message into a small piece of UI. This walkthrough is the implementation-grade version of the flagship chat automation use case: every field, every callback type, every error code, and the CLI flags that build the same payload without hand-writing JSON.

Request anatomy

The request body groups into six concerns. You will rarely use all of them in one call, but every production integration ends up touching most of them across its lifetime.

Group Fields
Targeting target_users, target_channels, target_teams, skip_users_on_time_off
Content message, messages, image_url, metadata
Threading bot_message_id, thread_responses
Identity send_as_user, platform_settings
Buttons buttons (array of Button objects)
Platform settings platform_settings (Slack-only overrides)

At least one of target_users / target_channels / target_teams is required (missing_targets otherwise). target_channels accepts a bare string channel id or an object { id, channel_type?, thread? } — set thread to reply into an existing platform thread rather than starting a new one. Either message (a string) or messages (an array of platform-specific payloads) is required.

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 finished","target_channels":["C0123456789"]}'

The response is {"bot_message_id": "$db/<uuid>"} — a server-generated id you keep for editing, or the value you supplied in the request to control it yourself.

Threads in one call

thread_responses posts a parent message plus up to 10 replies, one level deep, in a single request — no follow-up calls needed:

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": "Daily report — 2026-07-24",
    "target_channels": ["C0123456789"],
    "bot_message_id": "daily-report-2026-07-24",
    "thread_responses": [
      {"message": "alice: shipped the billing migration"},
      {"message": "bob: on PTO, no updates"}
    ]
  }'

The response returns bot_message_id for the parent plus one id per reply in thread_responses — every one of them is independently editable later with the same bot_message_id reuse trick. Threading renders natively in Slack channels and DMs; on Microsoft Teams, Discord, and Google Chat, replies thread inside channels but arrive flat in DMs (they still land, just without visual nesting).

Editing in place

Re-POST with the same bot_message_id within 72 hours to edit that message instead of sending a new one:

curl -sS -X POST 'https://api.dailybot.com/v1/send-message/' \
  -H 'X-API-KEY: $DAILYBOT_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"target_channels":["C0123456789"],"bot_message_id":"daily-report-2026-07-24","message":"Daily report — 2026-07-24 (updated)"}'

Identity overrides (bot_username, bot_icon_url, bot_icon_emoji) are ignored on an edit — the platform keeps the message’s original identity. Buttons are round-tripped: existing buttons survive an edit unless you send new button flags.

Identity: custom bot vs. send-as-user

Two independent ways to change who a message appears to come from:

  • Custom bot identity (any plan) — platform_settings.bot_username plus bot_icon_url or bot_icon_emoji (Slack only, requires the chat:write.customize scope; without it Slack silently falls back to the default bot identity and the call still returns ok: true).
  • send_as_user (Slack only, admin-only) — post with a real teammate’s Slack display name and avatar. The message still goes out on the Dailybot bot token, not the user’s own.

The two are mutually exclusive (send_as_user_conflict). An invalid send_as_user UUID fails client-side; a well-formed UUID that doesn’t resolve to a user returns send_as_user_not_found; a non-admin caller gets 403 org_admin_required.

dailybot chat send -c C0123 -m "Deploying the hotfix now" \
  --send-as-user 294bf2cc-e3c7-401d-a1d6-bf20aa64bb33

The five callback types

Every interactive button carries exactly one of five callbacks — combining two returns 400 button_callback_conflict:

Callback Fires Signed?
callback_url Your own HTTPS endpoint Yes — HMAC + optional callback_auth
callback_form Opens an internal Dailybot form No — fully internal
callback_command Runs a known ChatOps command as the clicker No — fully internal
callback_prompt Sends a free-text prompt to the Dailybot AI as the clicker No — fully internal
callback_workflow Triggers an api_trigger workflow as the clicker No — fully internal

callback_url — your own server

{"label": "Approve", "button_type": "interactive", "value": "approve",
 "callback_url": "https://ci.example.com/hooks/deploy",
 "callback_auth": {"type": "bearer", "token": "$DEPLOY_CALLBACK_TOKEN"}}

On click, Dailybot POSTs a signed body to your callback_url:

{
  "event": "button_click",
  "bot_message_id": "$db/ae007b43-...",
  "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 is one of slack, msteams, discord, or hangouts — Google Chat reports as hangouts, match on that value. callback_auth (bearer / basic / custom_header) is additive static transport auth, valid only with callback_url; the HMAC signature is always on regardless.

callback_form — open an internal form

{"label": "Submit expense", "button_type": "interactive", "value": "expense_form",
 "callback_form": "$EXPENSE_FORM_UUID"}

UUID only — names and slugs are rejected. Unknown/archived form → 400 button_callback_form_not_found. No external request fires; the form’s own lifecycle (see the forms deep-dive) takes over from there.

callback_command — run a ChatOps command

{"label": "Run help", "button_type": "interactive", "value": "help",
 "callback_command": "help"}

Runs as the clicking user (their permissions, their quota) — max 200 characters. The legacy "prompt: …" prefix is rejected (400 button_callback_command_invalid); use callback_prompt instead.

callback_prompt — hand the click to the AI

{"label": "Summarize thread", "button_type": "interactive", "value": "summarize",
 "callback_prompt": "Summarize the last 20 messages in this channel."}

Blank or oversized (> 2000 chars) → 400 button_callback_prompt_invalid.

callback_workflow — trigger a workflow

{"label": "Trigger rollback", "button_type": "interactive", "value": "rollback",
 "callback_workflow": "$ROLLBACK_WORKFLOW_UUID"}

Only workflows with event type api_trigger are eligible — find them with dailybot workflow list --filter api_trigger. Unknown/inactive/cross-org UUID → 400 button_callback_workflow_not_found. Steps in the triggered workflow read the click context via {{trigger.*}}: {{trigger.button_value}}, {{trigger.button_id}}, {{trigger.fields.<name>}} (modal inputs), {{trigger.user.*}}, and more — full reference on /developers/api/workflows.

Attach modal_body to an interactive button to open a lightweight modal first. Only three block types exist: text, input, divider — 1 to 10 blocks, serialized size ≤ 8 KiB.

{
  "title": "Log an update",
  "submit_label": "Save",
  "blocks": [
    {"type": "text", "text": "Share a quick status update."},
    {"type": "input", "name": "update_text", "label": "Update", "multiline": true, "required": true},
    {"type": "divider"}
  ]
}

name must match ^[a-z][a-z0-9_]{0,62}$ and be unique in the modal — submitted values arrive as modal_fields.<name> on callback_url, or {{trigger.fields.<name>}} on callback_workflow. A modal with input blocks requires callback_url or callback_workflow (input_without_callback); a display-only modal (only text/divider) needs neither.

response — instant auto-replies

Any button may carry a response object, shown to the clicker immediately (in parallel with a callback_url dispatch, never gated on its outcome — the canonical slow-approval pattern):

{"message": "Approved — deploying now.", "buttons": [], "replace_original": false, "ephemeral": false}

response.buttons is recursive (same Button schema), capped at nesting depth 3, 25 buttons per level, 16 KiB per top-level button.

Error codes at a glance

code Meaning
button_link_and_callback_conflict A link button mixed a callback or value in with url
button_callback_conflict More than one of the five callbacks on the same button
buttons_count_out_of_range More than 25 buttons on one message
button_modal_body_invalid Bad block type, ≥ 11 blocks, or an input modal with no callback
button_callback_auth_invalid callback_auth set without callback_url, or malformed
send_as_user_conflict send_as_user combined with bot_username/bot_icon_*

CLI flags: the same payloads without hand-written JSON

# Approval flow
dailybot chat send -c C0123456789 -m "Deploy 4.12.0 to production?" \
  --approve-button "Approve=approved" --reject-button "Reject=rejected" \
  --callback-url "https://ci.example.com/hooks/deploy" \
  --callback-bearer "$DEPLOY_CALLBACK_TOKEN"

# Workflow trigger button
dailybot chat send -c C0123456789 -m "Ready to redeploy staging?" \
  --workflow-button "Redeploy staging=$REDEPLOY_WORKFLOW_UUID"

# Anything the shorthand flags can't express — full JSON pass-through
dailybot chat send -c C0123456789 -m "Log a quick update" \
  --buttons '[{"label":"Add update","button_type":"interactive","value":"log_update",
    "callback_url":"https://ci.example.com/hooks/log-update",
    "modal_body":{"title":"Log an update","submit_label":"Save",
      "blocks":[{"type":"input","name":"update_text","label":"Update","multiline":true,"required":true}]}}]'

--buttons forwards keys untouched, including future fields the shorthand flags don’t expose yet — it is the escape hatch for anything this article shows as raw JSON above.

Everything here maps 1:1 to the interactive demo and the copy-paste recipes on the chat automation use case — read that page for the product-facing version of the same flow, or come back here whenever you need the exact field, the exact error code, or the exact CLI flag.

FAQ

What's the minimum request body for POST /v1/send-message/?
At least one of target_users, target_channels, or target_teams, plus either message (a string) or messages (an array of platform-specific payloads). Everything else — threads, buttons, identity overrides — is optional.
How many interactive buttons can one message carry, and what limits apply?
Up to 25 buttons per message. Each button is exactly one of button_type: "link" or "interactive". An interactive button carries at most one of the five callbacks (callback_url, callback_form, callback_command, callback_prompt, callback_workflow) — combining two returns 400 button_callback_conflict.
How do I verify that a callback_url POST really came from Dailybot?
Every callback POST carries X-Dailybot-Signature: t={unix_timestamp}, v1={hex_hmac}. Compute HMAC-SHA256 over the ASCII string "{timestamp}.{raw_body}" using your organization's callback signing secret, compare it in constant time, and reject signatures with a timestamp more than 5 minutes old.
How do I edit a message I already sent?
Re-POST to /v1/send-message/ with the same bot_message_id you received (or supplied) the first time, within a 72-hour window. The platform keeps the original bot identity — bot_username and bot_icon_* are ignored on edits.
What's the difference between callback_workflow and callback_form on a button?
callback_workflow triggers an internal Dailybot workflow (api_trigger type only) entirely server-side — no external POST, no signing. callback_form opens an internal Dailybot form for the clicker to fill. Both are mutually exclusive with callback_url and with each other.