Skip to content

Herald API Reference

Three credentials guard three separate surfaces, and each one’s absence unregisters its routes rather than leaving them half-open.

CredentialHeaderGuards
--send-keys (tenant:key,…)X-Herald-Key/api/v1/messages/* for that one tenant
--trusted-sender-keyX-Herald-Sender-Key/api/v1/messages/send-on-behalf for any tenant
--admin-tokenX-Admin-Token/admin/* and the operator console

Queues a message for the tenant the key maps to. The tenant is never read 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":"+549110000001",
"text":"Su reclamo fue actualizado."}'
# → 202 {"message_id":"...","status":"pending"}
FieldNotes
channelwhatsapp, echo, … — must have a registered account for this tenant
recipientChannel-native address
textThe message body
conversation_refOptional. Pins channel and recipient to an existing conversation and rides its open window

202 means queued. Delivery is asynchronous through the outbox.

The real status of a queued message, including how much of its 10-attempt retry budget is spent.

For a multi-tenant caller like Nexus. The credential does not identify a tenant, so the request must:

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":"+549110000001","text":"Su reclamo fue actualizado."}'

Herald does not validate tenant_id against a registry, deliberately — it has none. A tenant is whatever a configured key maps to, and inventing a registry here would create a second source of truth for tenant identity. An unknown tenant fails where it already does: no channel account matches.

All behind X-Admin-Token. Listings default to limit=50, max 200, clamped.

EndpointDescription
GET /admin/stats?tenant=&days=Messages per day and per channel, active conversations, outbox by status. days defaults to 7, max 90
GET /admin/conversations?tenant=&channel=&limit=&offset=Conversations, last_message_at DESC, with total
GET /admin/conversations/:id/messages?limit=&offset=One conversation’s thread, chronological
GET /admin/outbox?status=&channel=&tenant=&limit=&offset=Outbox monitor, created_at DESC, with total
POST /admin/sendOperator test send — the send API’s body plus an explicit tenant_id
POST/GET/PUT/DELETE /admin/bindings…Routing-rule CRUD, validated against Nexus
POST/GET/DELETE /admin/accounts…Channel-account CRUD; credentials masked (••• + last 4) in every response

GET/POST /webhooks/:channel is served generically. Signature verification and payload parsing belong to the adapter, not the HTTP layer. Channels with a subscription handshake (Meta’s) additionally implement ports.ChallengeVerifier, an optional interface the handler picks up by type assertion — so adding one does not change any existing adapter.

Any Go service that already knows which tenant it is acting for can import the public client. It depends only on the standard library.

import "github.com/monaccode/astromesh-herald/pkg/heraldclient"
hc := heraldclient.New(heraldURL, tenantHeraldKey, &http.Client{Timeout: 10 * time.Second})
resp, err := hc.Send(ctx, heraldclient.SendRequest{
Channel: "whatsapp",
Recipient: "+549110000001",
Text: "Su reclamo fue actualizado.",
// ConversationRef: "<conversation id>", // continue an existing conversation
})
status, err := hc.GetMessageStatus(ctx, resp.MessageID)

Without conversation_ref the send is a cold contact — on WhatsApp those only deliver inside the 24-hour window as free text, and need a template outside it.

Every flag defaults from an environment variable.

FlagEnvDefaultNotes
--database-urlDATABASE_URLRequired
--nexus-urlNEXUS_URLRequired
--enc-keyHERALD_ENC_KEYRequired. AES-GCM key sealing account secrets
--admin-tokenHERALD_ADMIN_TOKENemptyEmpty leaves /admin unregistered; ≥32 bytes if set
--send-keysHERALD_SEND_KEYSemptytenant:key,tenant:key. Empty leaves the send API unregistered
--trusted-sender-keyHERALD_TRUSTED_SENDER_KEYemptyEmpty leaves send-on-behalf unregistered; ≥32 bytes if set
--addr:8081
--outbox-poll-interval2s
--whatsapp-app-secretWHATSAPP_APP_SECRETemptySet with the verify token, or neither
--whatsapp-verify-tokenWHATSAPP_VERIFY_TOKENempty
--whatsapp-graph-base-urlWHATSAPP_GRAPH_BASE_URLhttps://graph.facebook.com/v21.0For Meta version bumps and tests
--enable-echoHERALD_ENABLE_ECHOfalseUnauthenticated development channel
--uiHERALD_UItrueEmbedded operator console at /ui/
  1. Implement ports.ChannelAdapterName, VerifyRequest, ParseIncoming, SendText, SendMedia — in internal/adapters/channel/<name>/.
  2. Add a domain.Channel<Name> constant.
  3. Register the adapter in the map in cmd/herald/main.go, behind its configuration. No config means no registration means the webhook is a 404.

Kustomize base with dev and mvp overlays, ArgoCD Applications and Image Updater configs, GHCR publishing.

Terminal window
deploy/bootstrap.sh <dev|mvp>

Prepares a channel namespace in one command: the four out-of-git Secrets, then the Application and the Image Updater. It refuses to touch herald-secrets once it exists — enc-key seals every stored channel credential and there is no re-encryption path.

Telegram, web chat and SMTP adapters (contract stubs are in place; each package documents its activation steps), FCM push, Twilio voice and SMS, streaming WebSocket runs toward Nexus, port-level template support for cold contacts, metrics and OTel, and multi-replica worker coordination beyond FOR UPDATE SKIP LOCKED.