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`.
On this page
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
| Name | Type | Required | Description |
|---|---|---|---|
| target_users | array<string> | Optional | User UUIDs, emails, or external IDs. At least one of target_users / target_channels / target_teams is required. |
| target_channels | array<string | object> | Optional | Channel IDs as strings, or objects { id, channel_type?, thread? }. Set thread (parent timestamp/id) to reply inside an existing thread. |
| target_teams | array<string> | Optional | Team UUIDs. Every member of the team receives the message. |
| skip_users_on_time_off | boolean | Optional | When true, users flagged as OOO / on leave are skipped. |
Content
| Name | Type | Required | Description |
|---|---|---|---|
| message | string | Optional | Plain text or a small HTML-safe subset. Required unless messages is set. |
| messages | array | Optional | Platform-specific payloads (advanced). Required if message is omitted. |
| image_url | string (https) | Optional | HTTPS image URL to attach to the message. |
| metadata | object | Optional | Free-form custom metadata attached to the message and echoed in callback bodies. |
Threading & editing
| Name | Type | Required | Description |
|---|---|---|---|
| bot_message_id | string | Optional | Custom idempotency ID. Re-POST the same value within 72 hours to edit the original message. |
| thread_responses | array<{ message: string }> | Optional | Up to 10 replies posted under the same parent in a single call. Only one level of nesting is allowed. |
Identity & impersonation
| Name | Type | Required | Description |
|---|---|---|---|
| send_as_user | string (UUID) | Optional | Slack-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
| Name | Type | Required | Description |
|---|---|---|---|
| buttons | array<Button> | Optional | Up 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
| Name | Type | Required | Description |
|---|---|---|---|
| platform_settings | object | Optional | Slack-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.
| Name | Type | Required | Description |
|---|---|---|---|
| label | string (≤ 40) | Required | Text rendered on the button. |
| label_after_click | string (≤ 40) | Optional | Replaces label after the button is clicked (or after modal submit for modal buttons). Defaults to label. |
| button_type | "link" | "interactive" | Required | link opens url in the browser. interactive triggers one of the callback capabilities below. |
| url | string (https) | Optional | Required when button_type = "link". The URL the user opens. |
| value | string (≤ 2000) | Optional | Required 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_url | string (https, ≤ 2048) | Optional | Signed outbound POST on click and on modal submit. Mutually exclusive with the other four callbacks. See Interactive buttons prose for HMAC verification. |
| modal_body | object | Optional | Opens 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_form | string (form UUID) | Optional | Opens an internal Dailybot form. UUID only (list via /v1/forms/). Mutually exclusive with other callbacks. Unknown/archived → 400 button_callback_form_not_found. |
| callback_command | string (≤ 200) | Optional | Runs 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_prompt | string (≤ 2000) | Optional | Free-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_workflow | string (workflow UUID) | Optional | Triggers 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. |
| response | object | Optional | Auto-reply shown to the clicker: { message (≤2000, required), buttons? (recursive), replace_original?, ephemeral? }. Composes with every button shape. Full contract: response object ↓. |
| callback_auth | object | Optional | Static 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_button | boolean | Optional | Default true. When false, the button stays clickable; every click is a fresh dispatch with a new X-Dailybot-Delivery id. |
| button_id | string ($btn/<uuid4>) | Optional | Server-minted. Echoed in every callback body. Client-supplied values are silently overwritten — do not set this field. |
| platform_settings | object | Optional | Rare per-platform overrides (legacy). Prefer top-level button fields. |
| payload | object | Optional | Free-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).
| Name | Type | Required | Description |
|---|---|---|---|
| title | string (≤ 200) | Required | Modal title. |
| submit_label | string (≤ 200) | Optional | Submit button label. Default: "Submit". |
| blocks | array (1..10) | Required | Exactly the block types text, input, and divider (1–10). Total serialized modal_body ≤ 8 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.
| Name | Type | Required | Description |
|---|---|---|---|
| type | "text" | "input" | "divider" | Required | Block kind. Only these three values are accepted. |
| text | string (≤ 3000) | Optional | Required for type: "text". Display-only copy. |
| name | string | Optional | Required 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>}}. |
| label | string (≤ 200) | Optional | Required for type: "input". Field label shown to the user. |
| multiline | boolean | Optional | Input only. Default false. |
| required | boolean | Optional | Input only. Default false. |
| placeholder | string (≤ 200) | Optional | Input only. Optional placeholder. |
| max_length | integer (1..3000) | Optional | Input only. Optional max length for the value. |
| default | string | Optional | Input 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).
| Name | Type | Required | Description |
|---|---|---|---|
| message | string (≤ 2000) | Required | Auto-reply text shown to the clicker. |
| buttons | array<Button> | Optional | Recursive 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_original | boolean | Optional | Replace the source message instead of posting a new one. Default false. |
| ephemeral | boolean | Optional | Slack-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.
| Name | Type | Required | Description |
|---|---|---|---|
| type | "bearer" | "basic" | "custom_header" | Required | Auth kind. Exactly the fields of the chosen type — extras are rejected (button_callback_auth_invalid). |
| token | string (≤ 4096) | Optional | Required for type: "bearer". Sent as Authorization: Bearer <token>. |
| username | string | Optional | Required for type: "basic" (with password). Sent as Authorization: Basic <base64>. |
| password | string | Optional | Required for type: "basic" (with username). |
| header_name | string (RFC 7230 token) | Optional | Required for type: "custom_header". Denied names: host, content-length, content-type, transfer-encoding, connection, user-agent, and anything starting with x-dailybot-. |
| header_value | string | Optional | Required for type: "custom_header". Sent as <header_name>: <header_value>. |
Response
| Name | Type | Required | Description |
|---|---|---|---|
| bot_message_id | string | Required | The ID you sent, or a server-generated $db/<uuid>. Reuse within 72h to edit. |
| thread_responses | array<string> | Optional | IDs of thread replies (only when thread_responses was sent). Each can be edited the same way as bot_message_id. |
Errors
| Status | When |
|---|---|
| 400 | Validation 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. |
| 401 | Missing, invalid, or expired credential. |
| 403 | Authenticated but not allowed — `org_admin_required` when a non-admin uses `send_as_user`; CLI role scoping returns `cli_send_message_target_not_allowed`. |
| 429 | Rate-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"]}'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"]}'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"}]}'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":"New release is live","target_channels":["C0123456789"],"buttons":[{"label":"Release notes","button_type":"link","url":"https://dailybot.com/changelog"},{"label":"Docs","button_type":"link","url":"https://dailybot.com/developers"}]}'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 to production - approve?","target_users":["U0123456789"],"buttons":[{"label":"Approve","button_type":"interactive","value":"approve","callback_url":"https://ci.example.com/dailybot/approve","callback_auth":{"type":"bearer","token":"$DEPLOY_CALLBACK_TOKEN"},"response":{"message":"Approved - deploying now."}},{"label":"Reject","button_type":"interactive","value":"reject","callback_url":"https://ci.example.com/dailybot/approve","callback_auth":{"type":"bearer","token":"$DEPLOY_CALLBACK_TOKEN"},"response":{"message":"Rejected."}}]}'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":"Pick a follow-up action","target_users":["U0123456789"],"buttons":[{"label":"Submit expense","button_type":"interactive","value":"expense_form","callback_form":"$EXPENSE_FORM_UUID"},{"label":"Trigger rollback","button_type":"interactive","value":"rollback","callback_workflow":"$ROLLBACK_WORKFLOW_UUID"},{"label":"Run help","button_type":"interactive","value":"help","callback_command":"help"},{"label":"Summarize thread","button_type":"interactive","value":"summarize","callback_prompt":"Summarize the last 20 messages in this channel."}]}'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":"Log a quick update","target_users":["U0123456789"],"buttons":[{"label":"Add update","button_type":"interactive","value":"log_update","callback_url":"https://ci.example.com/dailybot/log-update","modal_body":{"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"}]}}]}'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.
Open a conversation (API-Key only)
Open a conversation (API-Key only)
Errors
| Status | When |
|---|---|
| 401 | Missing/invalid/expired credential |
| 403 | Authenticated but not permitted |
| 429 | Throttled - Retry-After header set |
| 400 | Validation 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.
Send an email (API-Key only)
Body: to, subject, body_html, body_text.
Errors
| Status | When |
|---|---|
| 401 | Missing/invalid/expired credential |
| 403 | Authenticated but not permitted |
| 429 | Throttled - Retry-After header set |
| 400 | Validation 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.
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_userUUID) 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_useris mutually exclusive withbot_username,bot_icon_url, andbot_icon_emoji. Combining them returns400withcode: "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 theurlin 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.
Modal buttons
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 → Workflow
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.