API referenceMessages

Send a message from a Dairo inbox

Sends one message from the inbox named by inboxId to the addresses in to (plus optional cc/bcc, up to 50 recipients total); the inbox's own channel decides the transport — email, Telegram, or Slack.

Endpoint
POST https://api.dairo.app/v1/messages

Sends one message from the inbox named by inboxId to the addresses in to (plus optional cc/bcc, up to 50 recipients total); the inbox's own channel decides the transport — email, Telegram, or Slack. Provide exactly one body source — text, html, react, or a stored template — or send attachment-only (or a Telegram-only payload); zero or more than one body source is a 400. An immediate send delivers synchronously and returns the definitive status (sent with a providerMessageId, or failed), while a send with a future sendAt (RFC3339, at most 30 days out) returns status: scheduled after passing every gate. Set the Idempotency-Key header (or the body field idempotencyKey, ≤128 characters) so a retried send returns the original result instead of delivering twice. Inline base64 attachments are capped at 8 MiB each and 8 MiB per send across at most 10 files; attach a stored object by objectId to send up to 24 MiB. A delivered send emits the message.sent webhook event and is metered for billing per recipient (to + cc + bcc); it requires the messages:send scope, and sending email shows the full request shape. Set dryRun: true to validate a send without performing it: the request is checked exactly as a real send would be and a non-persisted preview (status: "preview") comes back, with nothing created, queued, sent, metered, or billed. Test mode is scoped to the REQUEST, not to the credential — a dairo_test_ API key still sends real, billable mail.

Scope: messages:send. Send the API key as a bearer token; Scopes lists what each one reaches.

Request

cURL
curl -X POST "https://api.dairo.app/v1/messages" \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "to": "person@example.com",
    "subject": "Welcome to Acme",
    "text": "Thanks for signing up."
  }'

Headers

NameTypeRequiredDescription
Idempotency-KeystringnoOptional idempotency key for safe retries of side-effecting operations. (max length 128)

Body

Required, application/json: SendMessageRequest.

FieldTypeRequiredDescription
inboxIdstringyes
toRecipientsyes
ccRecipientsno
bccRecipientsno
subjectstringno(default "")
textstringno
htmlstringno
idempotencyKeystringnoBody-level idempotency key kept for current API compatibility. Prefer Idempotency-Key header for new clients. (max length 128)
attachmentsarray of SendMessageAttachmentnoOutbound send attachments. 'attachment' and safe 'auto' sends are inline; current inline API limit is 10 files, 8 MiB per file, 8 MiB total decoded bytes. 'link' is explicit and must be backed by a pre-created Dairo share link placed deliberately in text/html by the caller. (max items 10)
reactReactRenderInputno
templateMessageTemplateRefno
sendAtstring (date-time) or nullnoRFC 3339 timestamp WITH an explicit timezone offset to schedule the send for a future time (max 30 days ahead). Omit to send immediately. A scheduled email starts with status scheduled and can be stopped via POST /v1/messages/{messageId}/cancel before it fires.
channel"email", "a2a"noOptional explicit delivery channel. "email" forces SES even when every recipient is a Dairo inbox; "a2a" requires every recipient to be a Dairo inbox (400 otherwise) and cannot combine with sendAt, inline attachments, replyTo, headers or tags. Absent = auto (a2a when all recipients are internal and the send carries none of those, else email). Recipients on special-use domains (.local, .test, .invalid, .example, .localhost, .onion, .alt, .internal, home.arpa), such as the internal tg-…@telegram.dairo.local address of a Telegram inbox, are a 400 on e-mail. Unknown values are a 400.
replyTostring (email)noOptional single Reply-To address, set verbatim as the MIME Reply-To header. E-mail only: a send with replyTo stays on e-mail even when every recipient is a Dairo inbox; channel a2a and Telegram or Slack inboxes answer 400.
inReplyTostringnoOptional RFC 5322 Message-ID this message replies to, with or without angle brackets (normalized to <id@host>). Stamped as the In-Reply-To header and used to place the sent copy in the parent's thread. Takes precedence over a custom In-Reply-To in headers. (max length 250)
referencesarray of stringnoOptional References chain of RFC 5322 Message-IDs (normalized like inReplyTo, deduplicated). Defaults to [inReplyTo] when omitted and inReplyTo is set. Takes precedence over a custom References in headers. (max items 32)
replyToMessageIdstring (uuid)noOptional Dairo message id (from the messages API) to reply to. Resolved server-side into inReplyTo, references (the parent's References plus its Message-ID) and the parent thread. 404 when the message is not visible to the caller; 400 when it disagrees with an explicit inReplyTo.
headersmap of stringnoOptional custom MIME headers ({name: value}). Spoofable/provenance headers are dropped server-side; malformed names or CR/LF are rejected. E-mail only: a send with headers stays on e-mail even when every recipient is a Dairo inbox; channel a2a and Telegram or Slack inboxes answer 400.
tagsmap of stringnoOptional SES message tags ({name: value}), validated against SES tag constraints. E-mail only: a send with tags stays on e-mail even when every recipient is a Dairo inbox; channel a2a and Telegram or Slack inboxes answer 400.
ignoreComplaintsbooleannoOverride complaint suppression for recipients that previously complained. (default false)
contactIdstringnoOptional contact reference: the contact's PRIMARY handle for the sending inbox's channel is added to to, so a send needs no raw address. Accepts a bare contact id, or a contact:<id> / @alias / @me ref. Recipients in to/cc/bcc may ALSO be contact references (@alias, contact:<id>, @me); raw addresses pass through unchanged.
buttonsarray of array of TelegramButtonnoTelegram inline keyboard (Telegram-channel inbox only): rows of {text, url|callback} buttons. A URL button opens a link; a callback button surfaces the tap back to you as an inbound button_tap event and shows a feedback toast on tap (default: ✓ + the button label; set alert:true for a modal).
telegramTelegramSpecialSendno
dryRunbooleannoPer-request dry run, at parity with the letters API's dryRun. When true the request is validated and the sending inbox resolved exactly as a real send would be, then a non-persisted MessagePreview (status "preview") is returned: nothing is created, queued, handed to the provider, metered, or billed. Absent or false is a normal send. Test mode is scoped to the REQUEST, not to the API key — an API key's environment label has no effect on delivery. (default false)

Response

200 Success. Body: application/json, SendMessageResponse.

FieldTypeRequiredDescription
idstring (uuid)yes
status"sent", "scheduled", "failed", "queued"yesA single immediate send (POST /v1/messages without sendAt) is performed synchronously and returns 'sent' (with providerMessageId) or 'failed' — it is never accepted as 'queued' only to fail later on something knowable at submit time (oversized attachment, unverified sender, etc.). A scheduled send (sendAt in the future) returns 'scheduled'. 'queued' is returned ONLY for a bulk list broadcast (POST /v1/audiences/{id}/send), whose batches are delivered asynchronously by the worker fleet.
providerMessageIdstring or nullno
errorstring or nullnoPublic-safe failure code/message. Provider/internal text is stored internally and never returned to API clients.
warningsarray of SendMessageWarningyesWarning-only delivery guidance. Complaint suppressions do not block sending; agents should surface these warnings before further contact.
channel"email", "a2a"noThe channel this send transited: "email" (SES) or "a2a" (internal Dairo-to-Dairo, delivered as a receipt, providerMessageId 'a2a_…').
channelMetadataobject or nullnoChannel-specific delivery metadata. For a2a: {receiptId, provenance, provenanceVerified}. For email: provider send metadata.
scheduledAtstring (date-time) or nullnoWhen a scheduled send will fire (RFC3339); present only when status is "scheduled".
200
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "sent",
  "providerMessageId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "error": null,
  "warnings": [
    {
      "recipient": "person@example.com",
      "reason": "complaint",
      "message": "Recipient previously complained; do not contact again unless you are sure.",
      "sourceMessageId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "providerMessageId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "complaintFeedbackType": null,
      "complaintUserAgent": null,
      "lastEventAt": "2026-10-19T09:30:00Z"
    }
  ],
  "channel": "email",
  "channelMetadata": {},
  "scheduledAt": "2026-10-19T09:30:00Z"
}

Errors

Every error has the same body, {"error": {"type", "code", "message", "param"}}; branch on code. Errors lists every code.

StatusMeaning
400The request is malformed or a value is invalid (invalid_request, invalid_domain, idempotency_key_mismatch).
401The API key is missing or invalid.
403The key lacks the scope this endpoint needs (scope_missing) or may not do this (not_permitted).
404No such resource in this account (resource_not_found).
422The request is well-formed but a field failed validation (validation_failed).
429Rate limit, plan limit, budget or spend cap reached (rate_limited, plan_limit_reached, budget_exceeded, spend_cap_reached).
500An unexpected error; retry with backoff, then contact support (internal_error).

On this page