{% extends "base.html" %} {% comment %} The public API reference. Server-rendered rather than Swagger UI: SECURITY-BASELINE §8 puts a nonce-based CSP on every page, which blocks a CDN-loaded console, and vendoring one would add an npm dependency to the audit job for a page read a handful of times per deployment. The machine-readable document is linked below. The endpoint table is generated from the OpenAPI schema (apps/api/views_docs.py), so it cannot disagree with the routes. {% endcomment %} {% block title %}API reference · DemandTrail Messaging{% endblock %} {% block page_header %}
A bearer-authenticated JSON API, plus signed outbound webhooks.
{% endblock %} {% block content %}Every request carries an API key issued from Settings → Organization → API keys:
Authorization: Bearer {{ token_prefix }}…
A key is scoped to exactly one workspace; there is no cross-workspace call and
no way to widen a key after issuance. Requests without a valid key get
401. Outside development a key sent over plain HTTP is refused,
it has already been logged by every hop that saw it.
| Scope | Grants |
|---|---|
{{ row.scope }} | {{ row.permissions }} |
write includes everything read does. No scope grants
key, channel, member or workspace-settings management: an API key cannot mint
another API key.
{{ rate_limit }} requests per second per key. Over the limit the answer is
429 with a Retry-After header; the window is one
second, so the header is the exact wait. Keys never contend with each other.
JSON in, JSON out. Bodies are capped at {{ max_body_bytes }} bytes, and unknown
keys are rejected rather than ignored. A typo in a field name is a
422, not a silently dropped value.
Every list endpoint answers with the same envelope:
{
"data": [ … ],
"has_more": true,
"next_cursor": "eyJvIjo1MH0"
}
Pass ?limit= (1–{{ max_limit }}, default {{ default_limit }}) and
?cursor=. The cursor is opaque: echo back next_cursor
and never construct one. Keep paging while has_more is true. There
is no total, because counting a workspace's contacts costs more than the page does.
Every non-2xx answer has the same shape:
{
"error": {
"code": "compliance_denied",
"message": "This contact opted out of messages on this channel.",
"detail": {"reason": "opted_out"}
}
}
| Code | Status | Meaning |
|---|---|---|
{{ code }} | {{ status }} | {{ meaning }} |
An object belonging to another workspace answers 404, exactly as a
non-existent one does. That is deliberate: a 403 would confirm the
id names something real.
| Method | Path | What it does |
|---|---|---|
{{ endpoint.method }} |
{{ endpoint.path }} |
{{ endpoint.summary|default:endpoint.description }} |
Configure endpoints per workspace under Settings → Workspace → Webhooks. Each delivery is a POST carrying:
{
"id": "0192f0c1-…",
"event": "contact.tag_added",
"workspace_id": "0192e8aa-…",
"occurred_at": "2026-08-23T09:14:02.481Z",
"data": {"contact_id": "0192e8ab-…", "tag_id": "0192e8ac-…"}
}
Ids only, never message bodies. Headers:
{{ signature_header }}, {{ timestamp_header }},
{{ event_header }} and {{ delivery_header }}.
| Event | Fires when |
|---|---|
{{ row.name }} | {{ row.label }} |
The signature is v1= followed by the hex HMAC-SHA256, over the
timestamp, a literal dot, and the raw request body, in that order, on the
bytes you received, before any JSON parsing.
import hashlib, hmac, time
def verify(secret, headers, raw_body, tolerance={{ timestamp_tolerance }}):
timestamp = headers["{{ timestamp_header }}"]
presented = headers["{{ signature_header }}"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, presented)
Compare in constant time, check the timestamp, and treat
{{ delivery_header }} as the deduplication key. Delivery is
at-least-once. Answer 2xx to acknowledge. A delivery that fails is
retried on a backoff; after {{ failure_limit }} consecutive failed deliveries
the endpoint is switched off and the workspace admins are notified.