Messaging API¶
The public REST API for sending messages and checking their status. Your backend calls it to send transactional and campaign messages to your customers — a welcome email, a password reset, a billing receipt, a campaign blast — and to ask, later, what happened to each one. It's the same surface our own SDKs are built on, so anything the Python client can do, you can do with a raw HTTP request.
This is the send-and-track half of Beacon. You hand us a recipient, a channel,
and a template; we render it, send it through the right provider, and record the
outcome as a delivery event. You learn the result one
of two ways: poll GET /messages/{id}, or — better for anything at scale —
subscribe to webhooks and let us push delivery outcomes to
you. Most integrations do both: webhooks for the live signal, polling as a fallback
and for reconciliation.
A quick map of where this fits before you dive in:
flowchart LR
you[Your backend] -->|POST /messages| api[Messaging API]
api --> ms[messaging-service]
ms -->|render + send| prov[Email / SMS / Push provider]
prov -->|outcome| ms
ms -->|delivery event| mongo[(MongoDB)]
ms -.->|webhook| you
you -.->|GET /messages/id| api
The provider, the queueing, the retries, and the delivery log all live behind the API in messaging-service — you never touch them directly. You only ever see the two endpoints below and the webhooks.
Quickstart¶
Send a message, then read it back. These two calls cover the great majority of real usage.
# Send
curl -X POST https://api.beacon.example/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Beacon-Version: 2026-05-01" \
-H "Idempotency-Key: welcome-7a3f9c" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"channel": "email",
"template": "welcome"
}'
{
"id": "msg_4Qk2...",
"to": "user@example.com",
"channel": "email",
"status": "queued",
"created": "2026-06-14T18:03:11Z"
}
The status you get back is almost always queued — the send is accepted and
handed off, not yet delivered. To find out how it actually went, read it back by
id:
# Check status
curl https://api.beacon.example/messages/msg_4Qk2... \
-H "Authorization: Bearer sk_live_..." \
-H "Beacon-Version: 2026-05-01"
{
"id": "msg_4Qk2...",
"to": "user@example.com",
"channel": "email",
"status": "delivered",
"created": "2026-06-14T18:03:11Z"
}
Delivery is asynchronous and can take a while — a provider may confirm seconds or
minutes later, and some channels are slower than others. Don't busy-poll waiting for
delivered; subscribe to webhooks instead and treat polling
as a backstop.
Basics¶
- Base URL / environments —
https://api.beacon.example(prod),https://api.sandbox.beacon.example(sandbox). Sandbox accepts the same calls but doesn't send real messages — use it to wire up your integration and exercise webhooks without emailing real people. Keys are environment-scoped (sk_live_…vssk_test_…), so a sandbox key against the prod URL simply fails auth. - Auth — bearer API key in
Authorization: Bearer <key>, scoped per environment. Sending needs themessages:sendscope; reading status needsmessages:read. Full details, scopes, and key handling on the Auth page. - Versioning — date-pinned via the
Beacon-Versionheader (e.g.2026-05-01). Breaking changes ship under a new date; old versions keep working for 12 months after a newer one lands. Pin a version explicitly in production so a future breaking change can't surprise you — omitting the header falls back to your account's default, which can move. - Content type — request and response bodies are JSON (
application/json). Timestamps are ISO-8601 UTC (createdisdate-time, read-only). - Errors — JSON
{code, message};429carries aRetry-After. See Errors below.
Reference¶
The authoritative endpoint reference is rendered from the OpenAPI spec, compiled
from the TypeSpec contracts in contracts/public-api/. The spec — not this page —
is ground truth for exact field names, types, and required/optional status; the
prose here exists to give you the why and the gotchas around it.
Endpoints at a glance¶
| Method & path | Scope | Does |
|---|---|---|
POST /messages |
messages:send |
Accept a message for delivery; returns it with a queued status |
GET /messages/{id} |
messages:read |
Read one message's current status |
That's the whole public surface today — two endpoints. Everything else (templates, scheduling internals, the delivery-event log) is owned and managed inside messaging-service and isn't exposed here.
Sending a message¶
POST /messages takes three required fields and one optional one:
| Field | Required | Notes |
|---|---|---|
to |
yes | The recipient. For email this is an address; the shape depends on the channel. |
channel |
yes | email | sms | push. See the channel note below. |
template |
yes | The template name to render. Templates live in messaging-service. |
sendAt |
no | ISO-8601 time to schedule the send. Omit to send now; set it to send later. |
You always send a template name, never raw message bodies — Beacon renders the template on its side. That's deliberate: it keeps content, localization, and compliance (unsubscribe footers, etc.) in one place rather than scattered across every caller.
Channel: email is the live path
The spec advertises email, sms, and push, but in practice email is
the channel that's actually exercised end-to-end. sms is a retired
feature — it appears only on legacy delivery-event
records, and the internal SMS consumer is
suspected dead. Treat sms (and anything
beyond email) as not currently supported in practice, regardless of what the
enum permits. If you need a non-email channel, confirm support before building
on it.
Message status¶
A message moves through these states. The set is fixed by the spec
(MessageStatus):
| Status | Meaning | Terminal? |
|---|---|---|
queued |
Accepted, not yet handed to a provider | no |
sent |
Handed to the provider, not yet confirmed delivered | no |
delivered |
Provider confirmed delivery | yes |
failed |
Retries exhausted | yes |
bounced |
Hard bounce (email) | yes |
stateDiagram-v2
[*] --> queued
queued --> sent
sent --> delivered
sent --> failed
sent --> bounced
delivered --> [*]
failed --> [*]
bounced --> [*]
The three terminal states map exactly to the outcomes recorded on a
delivery event (delivered / failed / bounced)
and to the webhook events of the same names. queued and
sent are in-flight stages you'll see while polling but never as a final answer.
Errors¶
Errors come back as JSON with a stable machine-readable code and a human
message:
Branch on code, not on message (the wording can change). The usual HTTP
conventions apply:
| Status | Meaning | What to do |
|---|---|---|
400 |
Malformed or invalid request | Fix the payload; don't retry as-is |
401 |
Missing or bad API key | Check the key and environment |
403 |
Key lacks the required scope | Grant messages:send / messages:read |
404 |
No message with that id |
Confirm the id (and the environment) |
429 |
Rate limited | Back off; honor the Retry-After header |
5xx |
Something failed on our side | Retry with backoff; idempotency-safe |
Rate limits and Retry-After
On a 429, the response carries a Retry-After header telling you how long to
wait before retrying. Respect it rather than retrying immediately — tight retry
loops just deepen the backlog. The Python SDK handles this
backoff for you.
Guides¶
- Authentication — API keys, scopes, environments, key handling.
- Webhooks — get delivery outcomes pushed to you instead of polling. The recommended way to learn results at scale.
- Python SDK — the official client, with retries and pagination built in.
How the pieces fit together¶
This API is one of three customer-facing surfaces on the messaging domain, and they solve different problems — reach for the right one:
| You want to… | Use |
|---|---|
| Send a message / look up one message's status | This API |
| Be notified of delivery outcomes without polling | Webhooks |
| Authenticate and manage keys | Auth |
| Skip raw HTTP and use idiomatic Python | Python SDK |
A typical lifecycle threads through all of them: authenticate with a key
(Auth) → POST /messages here → receive a
webhook when the outcome lands → optionally GET
/messages/{id} to reconcile.
Notes & nuances¶
Idempotency — make retries safe
POST /messages accepts an Idempotency-Key header so retries don't
double-send. If your request times out or you're unsure it landed, retry with
the same key and you'll get the original result back instead of a second
message. Choose a key that's stable for the logical send (e.g. derived from your
own record id, like welcome-<user_id>), not a fresh random value per attempt —
a new key on each try defeats the whole purpose.
Polling vs webhooks — pick the right tool
Polling GET /messages/{id} is fine for a one-off or a quick check, but it
doesn't scale: delivery can lag well behind the send, so you'd poll many times
per message. For production, subscribe to webhooks and
treat polling as a reconciliation backstop. Note the two surfaces use different
reliability models — the API is a synchronous request/response, while webhooks
are at-least-once and unordered, so a delivered webhook can arrive before
you've finished processing sent. Reconcile against current message state, not
event order.
Two endpoints, on purpose
There's no list-or-search-messages endpoint and no template-management endpoint in the public API today. Bulk reporting comes from the delivery-event log and the dashboard, not from this surface. If you find yourself wanting to enumerate messages over HTTP, you're probably reaching for webhooks (for the live stream) or dashboard exports (for history) instead.
Changelog
Breaking changes are introduced under new Beacon-Version dates and the prior
version is supported for 12 months. The current stable version is 2026-05-01
(the floor for SDK 2.x — see version compatibility). Pin a
date in production.
Confidence: 75/100 · Last reviewed: 2026-06-14 · Status: active
Verified against: beacon-messaging@7a8b9c0 (2026-05-22)