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.
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_usernameplusbot_icon_urlorbot_icon_emoji(Slack only, requires thechat:write.customizescope; without it Slack silently falls back to the default bot identity and the call still returnsok: 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.
modal_body — collecting input before the callback fires
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.