{% 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 %}

DemandTrail Messaging API v1

A bearer-authenticated JSON API, plus signed outbound webhooks.

{% endblock %} {% block content %}

Authentication

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.

Scopes

{% for row in scope_rows %} {% endfor %}
ScopeGrants
{{ 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 limits

{{ 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.

Requests

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.

Pagination

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.

Errors

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"}
  }
}
{% for code, status, meaning in error_codes %} {% endfor %}
CodeStatusMeaning
{{ 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.

Endpoints

{% for endpoint in endpoints %} {% endfor %}
MethodPathWhat it does
{{ endpoint.method }} {{ endpoint.path }} {{ endpoint.summary|default:endpoint.description }}

OpenAPI document (JSON)

Outbound webhooks

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 }}.

Events

{% for row in event_rows %} {% endfor %}
EventFires when
{{ row.name }}{{ row.label }}

Verifying a delivery

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.

{% endblock %}