Skip to content

API Reference

Herald listens on :8081. Errors are {"error": "<message>"}.

Three credentials guard three separate route groups. A credential that is not configured leaves its routes unregistered (404), never open.

CredentialHeaderGuardsConfigured with
Tenant send keyX-Herald-Key/api/v1/messages/send, /api/v1/messages/:id, for the tenant the key maps toHERALD_SEND_KEYS (tenant:key,tenant:key)
Trusted sender keyX-Herald-Sender-Keysend-on-behalf and identities/resolve, for any tenant named in the requestHERALD_TRUSTED_SENDER_KEY (32+ bytes)
Admin tokenX-Admin-TokenEverything under /admin, and the data behind the consoleHERALD_ADMIN_TOKEN (32+ bytes)

No credential works on another plane. A tenant key sent to send-on-behalf is 401, because that route trusts the tenant_id in the body. The trusted sender key carries no bindings, accounts or conversation contents, which is why it is not the admin token.

MethodPathAuthRegistered when
GET/healthz—Always
GET/webhooks/:channel—At least one channel
POST/webhooks/:channelChannel signatureAt least one channel
POST/webhooks/:channel/:cuentaChannel signatureAt least one channel
POST/api/v1/messages/sendTenant keySend keys set
GET/api/v1/messages/:idTenant keySend keys set
POST/api/v1/messages/send-on-behalfTrusted senderTrusted sender key set
GET/api/v1/identities/resolveTrusted senderTrusted sender key set
POST, GET/admin/accountsAdminAdmin token set
DELETE/admin/accounts/:idAdminAdmin token set
POST, GET/admin/bindingsAdminAdmin token set
PUT, DELETE/admin/bindings/:idAdminAdmin token set
GET/admin/statsAdminAdmin token set
GET/admin/conversationsAdminAdmin token set
GET/admin/conversations/:id/messagesAdminAdmin token set
GET/admin/outboxAdminAdmin token set
POST/admin/sendAdminAdmin token set
POST/admin/emulator/inboundAdminA channel is emulating
GET/admin/emulator/outboxAdminA channel is emulating
POST/admin/webchat/inboundAdmin--enable-webchat
GET/admin/webchat/hiloAdmin--enable-webchat
GET/, /ui/*—--ui (on by default)

The subscription handshake. Channels with one (WhatsApp, Instagram) return hub.challenge only when hub.verify_token matches, and 403 otherwise. Other channels echo any hub.challenge, or answer {"status":"ok"}.

POST /webhooks/:channel and /webhooks/:channel/:cuenta

Section titled “POST /webhooks/:channel and /webhooks/:channel/:cuenta”

A provider’s delivery. The second form carries the account in the URL, for channels whose payload does not name it (Telegram).

StatusMeaning
200 {"queued": n}Every message was queued. Processing happens afterwards
503 {"queued": n, "total": m}The inbound queue was full; the provider should retry
400The payload could not be parsed
401The signature did not verify
404The channel is not registered

Queues a message for the tenant the key maps to. The tenant never comes from the body.

Terminal window
curl -X POST localhost:8081/api/v1/messages/send \
-H "X-Herald-Key: <tenant key>" -H "Content-Type: application/json" \
-d '{"channel":"whatsapp","recipient":"5491100000001","text":"Su reclamo fue actualizado."}'
# → 202 {"message_id":"…","status":"pending"}
FieldNotes
channelwhatsapp, telegram, instagram, webchat, echo
recipientThe channel address: phone, chat id, Instagram-scoped id, visitor id
account_idOptional. Which of the tenant’s accounts it leaves from. Without it, the tenant’s oldest account on that channel
textThe message
mediaOptional list of {kind, url, mime_type, caption}. kind is image, audio, video or document; url must be public. Only the first item is sent
templateOptional {name, language, params}. WhatsApp only
conversation_refOptional conversation id. Its channel and person replace channel and recipient

At least one of text, media or template is required. A template needs name and language. No field may contain a NUL byte.

StatusMeaning
202Queued. Delivery is asynchronous
400Invalid request; the message says what to fix
404The conversation or the account does not exist for this tenant. An account of another tenant is reported the same way
500Storage failure; details stay in Herald’s log

The real outcome of a queued message, for the tenant that owns it.

{ "message_id": "…", "status": "sent", "attempts": 0, "last_error": "" }

status is pending, sending, sent or failed. attempts counts failed deliveries, out of 10. Another tenant’s message is 404.

The same body plus tenant_id, authenticated with the trusted sender key. This is the route Nexus uses when an agent calls send_message, and the route CLARUS’s schedulers use.

Terminal window
curl -X POST localhost:8081/api/v1/messages/send-on-behalf \
-H "X-Herald-Sender-Key: $HERALD_TRUSTED_SENDER_KEY" -H "Content-Type: application/json" \
-d '{"tenant_id":"tenant-1","channel":"whatsapp","recipient":"5491100000001",
"text":"Su reclamo fue actualizado."}'

Herald has no tenant registry: a tenant is whatever has accounts. An unknown tenant_id fails where it would anyway, with no matching account (404).

Which channel address verified a phone, for one tenant. Used to find a person’s Telegram chat from their phone number.

Terminal window
curl "localhost:8081/api/v1/identities/resolve?tenant_id=tenant-1&channel=telegram&phone=%2B5491100000001" \
-H "X-Herald-Sender-Key: $HERALD_TRUSTED_SENDER_KEY"

URL-encode phone: a raw + arrives as a space and never matches.

CaseStatusBody
One address verified this phone200{"external_user": "<address>"}
None did200{"external_user": null}
More than one did409{"error": …}
A parameter is missing400{"error": …}

“None” is 200 on purpose, so that a 404 still means the route does not exist.

Everything below requires X-Admin-Token.

RequestBehaviour
POST /admin/accounts{tenant_id, channel, external_id, credentials, emulated}. 201 when created; 200 when that tenant already had the account, with its credentials refreshed and emulated updated. On Telegram it registers the webhook first (502 if that fails)
GET /admin/accounts?tenant_id={"accounts": […]}. tenant_id is required
DELETE /admin/accounts/:idDeletes the account and its binding

Credentials per channel:

Channelexternal_idcredentials
WhatsAppphone_number_id{"access_token": …}
TelegramBot id{"bot_token": …}
InstagramInstagram account id{"access_token": …}
Web chatSlug{}
EchoAny id{}

Credentials are sealed at rest and come back as ••• plus the last four characters.

A binding is the routing rule for one account. Each account has at most one.

FieldNotes
tenant_id, channel, account_idFixed once created. Changing them means deleting and creating
entry_agentThe agent that answers new conversations. Required unless config.callback_url is set
fallback_messageSent when a run fails. Empty sends nothing
config.nexus_api_keyThe tenant’s Nexus key used for every run and for validation
config.callback_url, config.callback_secretForward instead of running an agent. See Webhook Forwarding
config.pedir_contactoTelegram: offer the contact-sharing button. See Telegram
RequestBehaviour
POST /admin/bindings201. 422 if the account does not exist, or if Nexus does not know the agent or rejects the key. 409 if the account already has a binding. 502 if Nexus could not be asked
GET /admin/bindings?tenant_id={"bindings": […]}, secrets in config masked
PUT /admin/bindings/:idUpdates entry_agent and fallback_message when given, and merges config. The agent is checked again
DELETE /admin/bindings/:id{"deleted": true}

A duplicate binding is a 409 and is not merged into the existing one: it carries its own entry agent, and taking over the existing row would hand this caller’s traffic to someone else’s agent. Masked values (•••…) are refused on write.

Listings take limit (default 50, maximum 200, larger values clamped) and offset.

RequestReturns
GET /admin/stats?tenant=&days=messages_per_day, messages_by_channel, active_conversations (activity within the window), outbox_by_status. days defaults to 7, maximum 90
GET /admin/conversations?tenant=&channel={conversations, total, limit, offset}, most recent activity first
GET /admin/conversations/:id/messages{messages, limit, offset}, oldest first
GET /admin/outbox?tenant=&status=&channel={entries, total, limit, offset}, newest first

tenant is optional on reads; without it they cover every tenant.

An operator’s test send: the send API body plus tenant_id, with the same answers. It has no account_id, so it always leaves from the tenant’s oldest account on the channel.

Registered when the WhatsApp or Instagram emulator is on.

RequestBehaviour
POST /admin/emulator/inbound{canal, cuenta, desde, texto, nombre}. canal defaults to whatsapp, cuenta to the first configured emulated id. desde and texto are required. 202 {"encolado": true, "channel_message_id": …}
GET /admin/emulator/outbox?canal=&cuenta=&ultimos={total, mensajes}, each with cuando, cuenta, destino, contenido. Oldest first, last 500 kept in memory

See Web Chat for POST /admin/webchat/inbound and GET /admin/webchat/hilo.

pkg/heraldclient is a client of the tenant send API with no dependencies outside the standard library.

import "github.com/monaccode/astromesh-herald/pkg/heraldclient"
hc := heraldclient.New(heraldURL, tenantSendKey, &http.Client{Timeout: 10 * time.Second})
resp, err := hc.Send(ctx, heraldclient.SendRequest{
Channel: "whatsapp",
Recipient: "5491100000001",
Text: "Su reclamo fue actualizado.",
// Media: []heraldclient.MediaAttachment{{Kind: "image", URL: "https://…"}},
// ConversationRef: "<conversation id>",
})
status, err := hc.GetMessageStatus(ctx, resp.MessageID)