API reference

HookChat API 1.0.0: 43 paths in 13 groups, generated from docs/openapi.yaml. Every /v1 response is the envelope { ok, data } or { ok: false, error }; branch on ok alone.

Servers#

  • https://hookchat.dev Production

Authentication#

bearerAuth (http bearer): The API key (hookchat_live_… or hookchat_test_…) presented as an HTTP bearer token: Authorization: Bearer hookchat_live_…. A single-tenant key is bound to its tenant; an operator key must name ?tenant within its allowlist. A missing or invalid key is a 401 unauthorized.

cookieAuth (apiKey in cookie (console_session)): The console session (I-602): a self-signed HS256 JWT set by the sign-in verify routes in the HttpOnly console_session cookie. A separate credential from the bearer key. It authenticates the /console/* and /oauth/*/start routes, and the /v1/* guard also accepts it as an alternate credential when no bearer is present (I-603), with the tenant derived from the user's memberships and csrfHeader enforced on mutations. An absent, invalid or expired session is a 401 unauthorized.

csrfHeader (apiKey in header (x-csrf-token)): The signed double-submit CSRF token (I-602). The readable console_csrf cookie carries a copy of the token bound into the session JWT; a console-session mutation (POST, PATCH, DELETE) must echo it in this header, compared in constant time against the JWT claim. A missing or mismatched header is a 403 csrf_failed. Bearer-key calls are never asked for it.

Operations#

health#

Unauthenticated liveness.

GET /#

Redirect the bare root to the marketing site

The apex is the API's custom domain, so a browser landing on the bare root is sent to the marketing site with a permanent redirect, the query string carried across. The route is mounted only when ROOT_REDIRECT_URL is configured; when it is unset (local/dev) nothing is mounted at / and Hono's plain-text not-found answers. Nothing else on the origin is affected.

Auth: none operationId root

Responses

301 Permanent redirect to the marketing site (ROOT_REDIRECT_URL set), with the original query string appended.

404 ROOT_REDIRECT_URL is unset, so no root route is mounted and Hono's default plain-text not-found answers.

404 example
404 Not Found

GET /health#

Bare liveness probe

Unauthenticated, no CORS, and not the /v1 envelope beyond { ok: true }. Infrastructure checks hit this; consumers should prefer GET /v1/ping, which also reports the service name and version.

Auth: none operationId health

Responses

200 The service is up.

200 example
{
  "ok": true
}

GET /v1/ping#

Liveness probe (unauthenticated, I-205)

Deliberately unauthenticated: mounted before the /v1/* bearer guard so a liveness probe needs no key. Carries no tenant data, only the service name, version and current time.

Auth: none operationId ping

Responses

200 Service is up.

200 example
{
  "ok": true,
  "data": {
    "name": "hookchat",
    "version": "0.1.0",
    "time": "2026-09-03T00:00:00Z"
  }
}

conversations#

The unified inbox feed and its window state.

GET /v1/conversations#

The unified inbox feed

Window state and the two "can I send" flags are computed server-side so no consumer reimplements Meta's rules. Paginated with an opaque cursor and a limit clamped to 1..100.

Auth: bearerAuth operationId listConversations

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of conversations.

200 example
{
  "ok": true,
  "data": {
    "conversations": [
      {
        "id": "string",
        "tenant_id": "string",
        "account_handle": "string",
        "platform": "instagram",
        "participant_id": "string",
        "participant_handle": "string",
        "last_inbound_at": "2026-09-03T00:00:00Z",
        "last_outbound_at": "2026-09-03T00:00:00Z",
        "last_message_text": "string",
        "window": {
          "state": "open_24h",
          "expires_at": "2026-09-03T00:00:00Z"
        },
        "unanswered": true,
        "can_reply": true,
        "can_send_as_human_agent": true
      }
    ],
    "next_cursor": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /v1/conversations/{id}#

One conversation's detail and thread history (I-201)

Returns the same server-computed conversation projection as the list feed (window state and the two "can I send" flags) plus the conversation's thread, its messages, newest-first, as a narrow projection carrying only id, direction, text and timing (never the raw Meta payload or any credential). TIER-1 tenant isolation: an id that does not exist OR belongs to another tenant is the SAME 404 conversation_not_found, never a 403 (which would confirm the id exists elsewhere) and never another tenant's data. The path id contains # separators and must be URL-encoded.

Auth: bearerAuth operationId getConversation

Parameters

NameInTypeRequiredDescription
idpathstringyesThe conversation id (the head key CONV#<tenant>#<platform>#<external_id>#<participant_id>). It contains # separators and MUST be URL-encoded in the path.
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The conversation and its thread.

200 example
{
  "ok": true,
  "data": {
    "conversation": {
      "id": "string",
      "tenant_id": "string",
      "account_handle": "string",
      "platform": "instagram",
      "participant_id": "string",
      "participant_handle": "string",
      "last_inbound_at": "2026-09-03T00:00:00Z",
      "last_outbound_at": "2026-09-03T00:00:00Z",
      "last_message_text": "string",
      "window": {
        "state": "open_24h",
        "expires_at": "2026-09-03T00:00:00Z"
      },
      "unanswered": true,
      "can_reply": true,
      "can_send_as_human_agent": true
    },
    "messages": [
      {
        "id": "string",
        "direction": "string",
        "text": "string",
        "sent_at": "2026-09-03T00:00:00Z",
        "sent_via": "string",
        "actor_id": "string"
      }
    ]
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such conversation (conversation_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "conversation_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

accounts#

A tenant's linked channel accounts and their credential health.

GET /v1/accounts#

The tenant's linked channel accounts (I-204)

Each account carries refresh_error, the last token-refresh failure so a silently disconnected account is visible rather than merely absent from the feed. No token or ciphertext is ever exposed.

Auth: bearerAuth operationId listAccounts

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The tenant's accounts (not paginated).

200 example
{
  "ok": true,
  "data": {
    "accounts": [
      {
        "account_key": "string",
        "platform": "string",
        "external_id": "string",
        "handle": "string",
        "display_name": "string",
        "page_id": "string",
        "status": "active",
        "refresh_error": "string",
        "last_refreshed_at": "2026-09-03T00:00:00Z"
      }
    ]
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

audit#

The tenant's audit trail.

GET /v1/audit#

The tenant's audit trail (I-109)

Most-recent-first. By construction every entry carries only who did what, to which resource, and from where, never message content, tokens or secrets. Paginated with an opaque cursor and a clamped limit.

Auth: bearerAuth operationId listAudit

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of audit entries.

200 example
{
  "ok": true,
  "data": {
    "audit": [
      {
        "id": "string",
        "action": "webhook.create",
        "resource": "string",
        "actor": "string",
        "ip": "string",
        "at": "2026-09-03T00:00:00Z"
      }
    ],
    "next_cursor": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

stats#

The dashboard overview counts (volume, error rate, DLQ).

GET /v1/stats#

The dashboard overview counts (I-207)

Tenant-scoped counts for the §6.2 dashboard overview: messages in the last 24h, per-endpoint delivery success/failure/DLQ, and the tenant DLQ total. Every number is a BOUNDED Query, never a Scan. The delivery counts and dlq_total are measured over a recent window (window_hours, default 24) rather than all delivery history, dlq_total is the sum of the per-endpoint dlq counts within that window. Only LIVE endpoints appear (tombstoned endpoints are excluded).

Auth: bearerAuth operationId getStats

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The tenant's overview counts.

200 example
{
  "ok": true,
  "data": {
    "messages_24h": 0,
    "dlq_total": 0,
    "window_hours": 0,
    "endpoints": [
      {
        "endpoint_id": "string",
        "delivered": 0,
        "failed": 0,
        "dlq": 0
      }
    ]
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

messages#

The two, and only two, send paths. No generic send, no tags.

GET /v1/messages#

The tenant-wide message feed (I-203)

Every message across the tenant's conversations, newest-first, on a sparse GSI that indexes messages only. Optionally narrowed by conversation_id (one conversation) and/or direction (inbound/outbound). Paginated with an opaque cursor and a limit clamped to 1..100; a filtered page may be short and still carry a next_cursor. Each message is the same narrow projection as the conversation thread, never the raw payload or a credential.

Auth: bearerAuth operationId listMessages

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.
conversation_idquerystringnoRestrict to a single conversation (its head id, URL-encoded).
directionqueryenum: inbound | outboundnoRestrict to one direction. Any other value is a 400 invalid_request.

Responses

200 A page of messages.

200 example
{
  "ok": true,
  "data": {
    "messages": [
      {
        "id": "string",
        "direction": "string",
        "text": "string",
        "sent_at": "2026-09-03T00:00:00Z",
        "sent_via": "string",
        "actor_id": "string"
      }
    ],
    "next_cursor": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /v1/messages/{id}#

One message by id (I-203)

A single message by its platform id (mid), tenant-scoped. An id that does not exist OR belongs to another tenant is the SAME 404 message_not_found, never a 403 and never another tenant's data.

Auth: bearerAuth operationId getMessage

Parameters

NameInTypeRequiredDescription
idpathstringyesThe platform's own message id (mid).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The message.

200 example
{
  "ok": true,
  "data": {
    "message": {
      "id": "string",
      "direction": "string",
      "text": "string",
      "sent_at": "2026-09-03T00:00:00Z",
      "sent_via": "string",
      "actor_id": "string"
    }
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such message (message_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "message_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/messages/reply#

Reply inside the 24-hour window

Sends a reply on a conversation whose 24-hour window is open. Out of that window this route refuses with window_closed (409); the human-agent route is the only remaining path. There is no ?tenant on this route the conversation id scopes the send. Body is snake_case; legacy camelCase (conversationId) is still tolerated but undocumented.

Auth: bearerAuth operationId replyInWindow

Request body

application/json, required

Request example
{
  "conversation_id": "string",
  "text": "string",
  "attachments": [
    {
      "type": "string",
      "url": "https://example.com"
    }
  ],
  "reply_to": "string"
}

Responses

200 The message was sent.

200 example
{
  "ok": true,
  "data": {
    "platform_message_id": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

404 No such conversation (conversation_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "conversation_not_found",
    "message": "string",
    "detail": "string"
  }
}

409 A policy refusal on a well-formed request, the thread's state, not the request, said no. code is window_closed or rate_limited.

409 example
{
  "ok": false,
  "error": {
    "code": "window_closed",
    "message": "string",
    "detail": "string"
  }
}

502 Policy allowed the send and the delivery itself failed (send_failed). The one non-policy failure, a retry can help. error.detail carries Meta's message.

502 example
{
  "ok": false,
  "error": {
    "code": "send_failed",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/messages/human-agent#

Send a human-typed message in the 24h–7d window

The only route out of the 24-hour window (24h–7d), human-sent. actor_id is required, the human who sent it must be named; omitting it is a 409 missing_actor policy refusal, not a 400. Past 7 days the window is human_agent_unavailable (409). Body is snake_case; legacy camelCase (conversationId/actorId) is tolerated but undocumented.

Auth: bearerAuth operationId sendAsHumanAgent

Request body

application/json, required

Request example
{
  "conversation_id": "string",
  "text": "string",
  "actor_id": "string",
  "attachments": [
    {
      "type": "string",
      "url": "https://example.com"
    }
  ],
  "reply_to": "string"
}

Responses

200 The message was sent.

200 example
{
  "ok": true,
  "data": {
    "platform_message_id": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

404 No such conversation (conversation_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "conversation_not_found",
    "message": "string",
    "detail": "string"
  }
}

409 A policy refusal. code is missing_actor (no actor_id), human_agent_unavailable (past 7 days) or rate_limited.

409 example
{
  "ok": false,
  "error": {
    "code": "missing_actor",
    "message": "string",
    "detail": "string"
  }
}

502 Policy allowed the send and the delivery itself failed (send_failed). The one non-policy failure, a retry can help. error.detail carries Meta's message.

502 example
{
  "ok": false,
  "error": {
    "code": "send_failed",
    "message": "string",
    "detail": "string"
  }
}

Error codes

webhooks#

Consumer webhook-endpoint administration (I-302).

GET /v1/webhooks#

List webhook endpoints (never returns a secret)

Auth: bearerAuth operationId listWebhooks

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The tenant's live endpoints.

200 example
{
  "ok": true,
  "data": {
    "endpoints": [
      {
        "id": "string",
        "url": "https://example.com",
        "events": [
          "string"
        ],
        "status": "active",
        "signing_secret_prefix": "string",
        "secondary_active": true,
        "secondary_expires_at": "2026-09-03T00:00:00Z",
        "created_at": "2026-09-03T00:00:00Z",
        "updated_at": "2026-09-03T00:00:00Z"
      }
    ]
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/webhooks#

Register a webhook endpoint (secret shown once)

Creates an endpoint and mints its signing secret. The plaintext signing_secret (whs_…) is returned exactly once here; no read path ever returns it. A rejected URL (SSRF gate) is a 400 with the reason in error.detail.

Auth: bearerAuth operationId createWebhook

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Request body

application/json, required

Request example
{
  "url": "https://example.com",
  "events": [
    "string"
  ]
}

Responses

200 The endpoint plus the one-time plaintext signing_secret (create/rotate only).

200 example
{
  "ok": true,
  "data": {
    "endpoint": {
      "id": "string",
      "url": "https://example.com",
      "events": [
        "string"
      ],
      "status": "active",
      "signing_secret_prefix": "string",
      "secondary_active": true,
      "secondary_expires_at": "2026-09-03T00:00:00Z",
      "created_at": "2026-09-03T00:00:00Z",
      "updated_at": "2026-09-03T00:00:00Z"
    },
    "signing_secret": "whs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /v1/webhooks/{id}#

Fetch one endpoint (never returns a secret)

Auth: bearerAuth operationId getWebhook

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The endpoint (never carries a secret).

200 example
{
  "ok": true,
  "data": {
    "endpoint": {
      "id": "string",
      "url": "https://example.com",
      "events": [
        "string"
      ],
      "status": "active",
      "signing_secret_prefix": "string",
      "secondary_active": true,
      "secondary_expires_at": "2026-09-03T00:00:00Z",
      "created_at": "2026-09-03T00:00:00Z",
      "updated_at": "2026-09-03T00:00:00Z"
    }
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

PATCH /v1/webhooks/{id}#

Update url/events and/or pause/resume

Any subset of url, events, status. status accepts only active or paused (delete is its own route). A new url is re-validated by the SSRF gate (400 with the reason in error.detail on rejection). Returns the updated endpoint; never returns a secret.

Auth: bearerAuth operationId updateWebhook

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Request body

application/json, required

Request example
{
  "url": "https://example.com",
  "events": [
    "string"
  ],
  "status": "active"
}

Responses

200 The endpoint (never carries a secret).

200 example
{
  "ok": true,
  "data": {
    "endpoint": {
      "id": "string",
      "url": "https://example.com",
      "events": [
        "string"
      ],
      "status": "active",
      "signing_secret_prefix": "string",
      "secondary_active": true,
      "secondary_expires_at": "2026-09-03T00:00:00Z",
      "created_at": "2026-09-03T00:00:00Z",
      "updated_at": "2026-09-03T00:00:00Z"
    }
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

DELETE /v1/webhooks/{id}#

Tombstone an endpoint (signing material erased)

Tombstone: status becomes deleted, both signing secrets are erased, the id and history are retained.

Auth: bearerAuth operationId deleteWebhook

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 Deleted.

200 example
{
  "ok": true,
  "data": {
    "deleted": true
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/webhooks/{id}/rotate#

Rotate the signing secret (new secret shown once)

Mints a new primary signing secret and demotes the current one to a secondary that still verifies for a 24-hour overlap. The new plaintext signing_secret is returned exactly once.

Auth: bearerAuth operationId rotateWebhookSecret

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The endpoint plus the one-time plaintext signing_secret (create/rotate only).

200 example
{
  "ok": true,
  "data": {
    "endpoint": {
      "id": "string",
      "url": "https://example.com",
      "events": [
        "string"
      ],
      "status": "active",
      "signing_secret_prefix": "string",
      "secondary_active": true,
      "secondary_expires_at": "2026-09-03T00:00:00Z",
      "created_at": "2026-09-03T00:00:00Z",
      "updated_at": "2026-09-03T00:00:00Z"
    },
    "signing_secret": "whs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /v1/webhooks/{id}/deliveries#

List an endpoint's deliveries, newest first (I-306)

One page of the endpoint's delivery ledger, newest first, over the tenant-partitioned deliveries index, so a page can only ever hold this tenant's deliveries. Each row is the consumer-safe summary view: no body, no url, never a secret. Timestamps on delivery rows are epoch milliseconds (an internal replay surface, not a customer resource shape). A read, so it is not audited. An endpoint id that does not exist under this tenant yields an empty page, not a 404. Paginated with the opaque cursor and a limit clamped to 1..100.

Auth: bearerAuth operationId listWebhookDeliveries

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of delivery summaries.

200 example
{
  "ok": true,
  "data": {
    "deliveries": [
      {
        "delivery_id": "string",
        "event_id": "string",
        "event_type": "message.received",
        "status": "pending",
        "attempt_number": 0,
        "next_retry_at": 0,
        "last_response_status": 0,
        "created_at": 0,
        "updated_at": 0
      }
    ],
    "next_cursor": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /v1/webhooks/{id}/deliveries/summary#

The endpoint's failing deliveries in a recent window

The endpoint's currently failing deliveries, aggregated server-side for the console's failed-in-the-last-24h view. Defaults to the last 24 hours and the statuses dlq, failed, blocked and retrying (a delivery that is backing off is failing right now, so it belongs here even though it is never a replay target). The store walks the window newest first and paginates internally, so a recent failure is never hidden behind older rows; there is no external cursor. truncated is true when the bounded examined-row cap was hit and only the most recent failures are returned. A read, so it is not audited.

Auth: bearerAuth operationId summarizeWebhookDeliveries

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
sincequerystringnoStart of the window, ISO 8601 or epoch milliseconds. Defaults to 24 hours ago. Anything else is a 400 invalid_request.
statusquerystringnoComma-separated subset of dlq,failed,blocked,retrying. Defaults to all four. Any other value is a 400 invalid_request.

Responses

200 The failing deliveries in the window, newest first.

200 example
{
  "ok": true,
  "data": {
    "deliveries": [
      {
        "delivery_id": "string",
        "event_id": "string",
        "event_type": "message.received",
        "status": "pending",
        "attempt_number": 0,
        "next_retry_at": 0,
        "last_response_status": 0,
        "created_at": 0,
        "updated_at": 0
      }
    ],
    "truncated": true
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/webhooks/{id}/deliveries/replay#

Bulk replay the deliveries in a time and status window (I-307)

A bounded, idempotent batch re-drive of the endpoint's deliveries whose creation time falls in [from, to] and whose status is in status. from, to, status and force may come from the query string or the JSON body; the query wins. from is required; to defaults to now and must not precede from; status defaults to the retryable set dlq,failed. In-flight states (pending, attempting, retrying) are never selectable, which is what keeps a re-run idempotent. Including delivered is refused with a 409 replay_conflict unless force is true, because a forced re-send makes the consumer see a duplicate event id (which is why dedupe-on-id exists). Each match is reset to a fresh attempt budget and re-enqueued; a delivery mid-attempt is counted in skipped, never double-enqueued. limit (query only) caps the rows examined per call and next_cursor resumes the next page. An endpoint that does not exist under this tenant is a 404 before any query. Audited as webhook.bulk_replay with the window, statuses and counts, never a secret, url or body.

Auth: bearerAuth operationId bulkReplayWebhookDeliveries

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
fromquerystringnoWindow start, ISO 8601 or epoch milliseconds. Required here or in the body.
toquerystringnoWindow end, ISO 8601 or epoch milliseconds. Defaults to now.
statusquerystringnoComma-separated subset of dlq,failed,blocked,delivered. Defaults to dlq,failed.
forcequeryenum: truenoMust be the literal true to replay a delivered delivery (or to include delivered in a bulk status set). Any other value is treated as absent.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Request body

application/json, optional

Request example
{
  "from": "string",
  "to": "string",
  "status": "string",
  "force": true
}

Responses

200 The batch outcome for this page.

200 example
{
  "ok": true,
  "data": {
    "matched": 0,
    "enqueued": 0,
    "skipped": 0,
    "next_cursor": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

409 Replay refused (replay_conflict): the delivery already succeeded and force was not given (a forced re-send makes the consumer see a duplicate event id), or the delivery is currently mid-attempt.

409 example
{
  "ok": false,
  "error": {
    "code": "replay_conflict",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay#

Replay one delivery with a fresh attempt budget (I-306)

Resets the delivery to attempt 0, status pending, due now, and re-enqueues it through the real signing and delivery path. A dlq, failed or blocked delivery replays freely. A delivered one is refused with a 409 replay_conflict unless ?force=true, since the consumer will then see a duplicate event id (which is why dedupe-on-id exists). A delivery that is mid-attempt is also a 409 replay_conflict; try again shortly. Tenant-scoped: an endpoint or delivery that does not exist or belongs to another tenant is a 404, never a 403. Audited as webhook.replay.

Auth: bearerAuth operationId replayWebhookDelivery

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
deliveryIdpathstringyesThe delivery id (deterministic per event and endpoint).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.
forcequeryenum: truenoMust be the literal true to replay a delivered delivery (or to include delivered in a bulk status set). Any other value is treated as absent.

Responses

200 The delivery was reset and re-enqueued.

200 example
{
  "ok": true,
  "data": {
    "delivery_id": "string",
    "status": "pending"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found), or no such delivery under it (delivery_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

409 Replay refused (replay_conflict): the delivery already succeeded and force was not given (a forced re-send makes the consumer see a duplicate event id), or the delivery is currently mid-attempt.

409 example
{
  "ok": false,
  "error": {
    "code": "replay_conflict",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/webhooks/{id}/test#

Send a test event through the real delivery path

Mints a conforming test.event and enqueues it to this one endpoint through the real signing and delivery path, so a developer can prove their receiver verifies signatures. Returns the new delivery id; the delivery itself runs asynchronously and can be followed on the deliveries list. Audited as webhook.test.

Auth: bearerAuth operationId testWebhook

Parameters

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The test event was enqueued.

200 example
{
  "ok": true,
  "data": {
    "delivery_id": "string"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such (or tombstoned) endpoint (endpoint_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "endpoint_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

keys#

API-key management, create (secret shown once), list (prefix only), revoke.

GET /v1/keys#

List API keys (prefix only, never a secret)

The tenant's API keys. Each row carries the prefix only, the plaintext secret is never stored, so it can never appear here.

Auth: bearerAuth operationId listKeys

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The tenant's API keys.

200 example
{
  "ok": true,
  "data": {
    "keys": [
      {
        "id": "string",
        "prefix": "string",
        "scope": "live",
        "label": "string",
        "status": "active",
        "created_at": "2026-09-03T00:00:00Z"
      }
    ]
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /v1/keys#

Mint an API key (secret shown once)

Mints a single-tenant (bound) API key for the caller's tenant and returns its plaintext secret (hookchat_live_… / hookchat_test_…) EXACTLY ONCE, no read path ever returns it again (only its sha256 hash is stored, so a lost key is unrecoverable; rotate by creating a new key and revoking this one). The new key's scope defaults to the caller's own scope; a test-scope caller may not create a live key (a 403 forbidden). This is the same hashed-key core the key:* CLI drives. Every create writes an audit row.

Auth: bearerAuth operationId createKey

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Request body

application/json, optional

Request example
{
  "scope": "live",
  "label": "string"
}

Responses

200 The key was minted; secret is shown exactly once.

200 example
{
  "ok": true,
  "data": {
    "id": "string",
    "prefix": "string",
    "secret": "hookchat_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "scope": "live",
    "label": "string",
    "created_at": "2026-09-03T00:00:00Z"
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

DELETE /v1/keys/{id}#

Revoke an API key (idempotent)

Revokes the key. Idempotent, revoking an already-revoked key still 200s. The revoke is scoped to the caller's tenant, so a cross-tenant key id finds no key and is a 404 key_not_found, never a 403 that would confirm the id exists under another tenant. A real revoke writes an audit row.

Auth: bearerAuth operationId revokeKey

Parameters

NameInTypeRequiredDescription
idpathstringyesThe API key id (a ULID).
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The key was revoked.

200 example
{
  "ok": true,
  "data": {
    "revoked": true
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such key under this tenant (key_not_found). A cross-tenant id is a 404, never a 403.

404 example
{
  "ok": false,
  "error": {
    "code": "key_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

console#

Console-session (cookie-authed) routes, the operator's own identity.

GET /console/me#

The logged-in console operator's identity (I-604)

Returns the authenticated console operator's {actor_id, email, tenant, tenants}. Authed by the console SESSION COOKIE (I-602), NOT a bearer key an absent/invalid/expired session is a 401 unauthorized. actor_id is console:<userId>, the same stable operator identity every audited console mutation attributes to; the dashboard attaches it as a human-agent send's actor_id. tenant is the bound tenant for a single-membership operator (else null); tenants is the full membership set. A pure read of the session and membership, so no CSRF token is required and nothing is audited.

Auth: cookieAuth operationId consoleMe

Responses

200 The console operator's identity.

200 example
{
  "ok": true,
  "data": {
    "user_id": "string",
    "actor_id": "console:01H…",
    "email": "user@example.com",
    "tenant": "string",
    "tenants": [
      "string"
    ]
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/session/ws-ticket#

Mint a WebSocket connect ticket for a console session (I-309)

The console-session counterpart of POST /v1/realtime/ticket. Authed by the session cookie plus the CSRF header (a POST is a mutation under the console rules; a missing or wrong header is a 403 csrf_failed). The tenant is membership-derived, never client-trusted: a single-membership user's tenant is implicit, a multi-membership user must name an allowed ?tenant (omitting it is a 400 tenant_required, naming one they do not belong to is a 403 forbidden). The ticket carries only the tenant and actor, never the session.

Auth: cookieAuth + csrfHeader operationId consoleRealtimeTicket

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug for a console session. Optional for a single-membership user (it defaults to their one workspace; naming a different one is a 403 forbidden). Required for a multi-membership user (omitting it is a 400 tenant_required) and must be one they belong to.

Responses

200 The single-use connect ticket, shown exactly once.

200 example
{
  "ok": true,
  "data": {
    "ticket": "string",
    "expires_at": 0,
    "expires_in_ms": 0
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 csrf_failed (the x-csrf-token header is missing or wrong) or forbidden (the named ?tenant is not one the caller belongs to).

403 example
{
  "ok": false,
  "error": {
    "code": "csrf_failed",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/auth/request#

Request a magic sign-in link (I-602)

Mints a single-use, hashed, time-limited sign-in token for email and emails a link to GET /console/auth/verify. Unauthenticated. Always 200 on a well-formed request whether or not the address maps to an existing user, so the route cannot be used to enumerate accounts. Rate limited per email (5) and per attested source IP (20) over a 15 minute window; exceeding either cap is a 429 rate_limited with a Retry-After header. redirect is validated to a same-site relative path; anything else falls back to /.

Auth: none operationId consoleAuthRequest

Request body

application/json, required

Request example
{
  "email": "user@example.com",
  "redirect": "/inbox"
}

Responses

200 The request was accepted (whether or not the address is known).

200 example
{
  "ok": true,
  "data": {
    "sent": true
  }
}

400 Not a JSON body, or email is missing or not an email (invalid_request).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

429 The per-email or per-IP sign-in cap was exceeded (rate_limited).

429 example
{
  "ok": false,
  "error": {
    "code": "rate_limited",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /console/auth/verify#

Consume an emailed sign-in link and start a session

The emailed link. Consumes the single-use token, upserts the console user, claims any pending team invites for the address (best effort, after the address is proven), sets the console_session (HttpOnly) and console_csrf (readable, for the double-submit header) cookies, and 302-redirects to the validated post-login path on the console origin. The token is the credential, so no other security applies. Every consume failure (expired, already used, unknown) is the same 401 invalid_token, never an oracle for which tokens exist.

Auth: none operationId consoleAuthVerifyLink

Parameters

NameInTypeRequiredDescription
tokenquerystringyesThe single-use sign-in token from the email.

Responses

302 Signed in. Redirect to the console with the session and CSRF cookies set.

400 No token (invalid_request).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 The sign-in token is invalid, expired or already used (invalid_token). Every consume failure is this same response.

401 example
{
  "ok": false,
  "error": {
    "code": "invalid_token",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/auth/verify#

Consume a sign-in token and start a session (JSON)

The programmatic form of the emailed link, for the console SPA. The token is read from ?token first, then from the JSON body. On success the same two cookies are set and the body returns the validated redirect path and the csrf token to echo in the x-csrf-token header on later mutations. Failures are identical to the GET form.

Auth: none operationId consoleAuthVerify

Parameters

NameInTypeRequiredDescription
tokenquerystringnoThe sign-in token; takes precedence over the body.

Request body

application/json, optional

Request example
{
  "token": "string"
}

Responses

200 Signed in. The session and CSRF cookies are set.

200 example
{
  "ok": true,
  "data": {
    "redirect": "string",
    "csrf": "string"
  }
}

400 No token in the query or body (invalid_request).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 The sign-in token is invalid, expired or already used (invalid_token). Every consume failure is this same response.

401 example
{
  "ok": false,
  "error": {
    "code": "invalid_token",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/auth/logout#

End the console session

Clears the console_session and console_csrf cookies. A CSRF-protected mutation: the session cookie and a matching x-csrf-token header are both required.

Auth: cookieAuth + csrfHeader operationId consoleAuthLogout

Responses

200 The session was cleared.

200 example
{
  "ok": true,
  "data": {
    "loggedOut": true
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The session is valid but the x-csrf-token header is missing or wrong (csrf_failed).

403 example
{
  "ok": false,
  "error": {
    "code": "csrf_failed",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /console/tenants#

The caller's workspaces

The caller's memberships, one row per workspace with their role (defaulting to member).

Auth: cookieAuth operationId consoleListTenants

Responses

200 The caller's workspaces.

200 example
{
  "ok": true,
  "data": {
    "tenants": [
      {
        "slug": "string",
        "role": "owner"
      }
    ]
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/tenants#

Create a workspace and become its owner

Self-serve workspace creation, the escape from the no-tenant dead end. The slug is reserved and the caller's owner membership written in one atomic transaction; an already-claimed slug is a 409 tenant_slug_taken. A slug is 3 to 40 characters of lowercase letters, digits and hyphens, starting with a letter and ending alphanumeric, and may not be a reserved word (console, admin, api, oauth, webhooks, health, v1, internal, system). This route can only ever create a new, unclaimed slug; joining an existing workspace is the invite flow.

Auth: cookieAuth + csrfHeader operationId consoleCreateTenant

Request body

application/json, required

Request example
{
  "slug": "string",
  "name": "string"
}

Responses

200 The workspace was created.

200 example
{
  "ok": true,
  "data": {
    "slug": "string",
    "role": "owner"
  }
}

400 Not a JSON body, or the slug is malformed or reserved (invalid_request).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The session is valid but the x-csrf-token header is missing or wrong (csrf_failed).

403 example
{
  "ok": false,
  "error": {
    "code": "csrf_failed",
    "message": "string",
    "detail": "string"
  }
}

409 The slug is already claimed (tenant_slug_taken).

409 example
{
  "ok": false,
  "error": {
    "code": "tenant_slug_taken",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /console/tenants/{slug}/members#

A workspace's members and pending invites

For the Team UI. Only an owner or admin of this workspace may read it; a non-member, a plain member, or a slug the caller cannot manage is the same 403 forbidden and never reveals whether the workspace exists.

Auth: cookieAuth operationId consoleListTeam

Parameters

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.

Responses

200 The active members and pending invites.

200 example
{
  "ok": true,
  "data": {
    "members": [
      {
        "user_id": "string",
        "email": "user@example.com",
        "role": "owner",
        "status": "active"
      }
    ],
    "invites": [
      {
        "email": "user@example.com",
        "role": "admin",
        "status": "pending"
      }
    ]
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The caller is not an owner or admin of this workspace (forbidden). Never reveals whether the workspace exists.

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

DELETE /console/tenants/{slug}/members/{userId}#

Remove a member from a workspace

Owner or admin only, CSRF-protected. An owner membership cannot be removed here (it would orphan the workspace) and is a 409 owner_immutable; a user who is not a member is a 404 member_not_found.

Auth: cookieAuth + csrfHeader operationId consoleRemoveMember

Parameters

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.
userIdpathstringyesThe console user id of the member to remove.

Responses

200 The member was removed.

200 example
{
  "ok": true,
  "data": {
    "removed": true
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 forbidden (the caller is not an owner or admin of this workspace) or csrf_failed (the x-csrf-token header is missing or wrong).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such member of this workspace (member_not_found).

404 example
{
  "ok": false,
  "error": {
    "code": "member_not_found",
    "message": "string",
    "detail": "string"
  }
}

409 The target is an owner and cannot be removed (owner_immutable).

409 example
{
  "ok": false,
  "error": {
    "code": "owner_immutable",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /console/tenants/{slug}/invites#

Invite a teammate by email

Owner or admin only, CSRF-protected. Records a pending invite that grants nothing until the invitee signs in and it is claimed at verify time, so an invite to an address that never authenticates never exposes any data. The membership gate runs before the body is read, so a non-member with a malformed body still sees the 403. role defaults to member; owner cannot be granted by invite.

Auth: cookieAuth + csrfHeader operationId consoleInviteMember

Parameters

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.

Request body

application/json, required

Request example
{
  "email": "user@example.com",
  "role": "member"
}

Responses

200 The invite was recorded.

200 example
{
  "ok": true,
  "data": {
    "email": "user@example.com",
    "role": "admin",
    "tenant": "string"
  }
}

400 Not a JSON body, email missing or invalid, or role not admin or member (invalid_request).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 forbidden (the caller is not an owner or admin of this workspace) or csrf_failed (the x-csrf-token header is missing or wrong).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

DELETE /console/tenants/{slug}/invites/{email}#

Revoke a pending invite

Owner or admin only, CSRF-protected. The email path segment is URL-decoded once by the router; do not double-encode it. No such pending invite is a 404 invite_not_found.

Auth: cookieAuth + csrfHeader operationId consoleRevokeInvite

Parameters

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.
emailpathstring (email)yesThe invited address, URL-encoded once.

Responses

200 The invite was revoked.

200 example
{
  "ok": true,
  "data": {
    "revoked": true
  }
}

401 No valid console session cookie (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 forbidden (the caller is not an owner or admin of this workspace) or csrf_failed (the x-csrf-token header is missing or wrong).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 No such pending invite (invite_not_found).

404 example
{
  "ok": false,
  "error": {
    "code": "invite_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

realtime#

The single-use WebSocket connect-ticket mint (I-309).

POST /v1/realtime/ticket#

Mint a single-use WebSocket connect ticket (I-309)

A browser cannot set an Authorization header on a WebSocket upgrade, so a short-lived, single-use, tenant-bound ticket is minted here over an authenticated call and passed in the WebSocket URL's query string; the realtime endpoint verifies and consumes it at connect. The tenant is derived server-side from the key's binding exactly like every /v1 read. The ticket carries only the tenant and actor, never the credential.

Auth: bearerAuth operationId createRealtimeTicket

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug. For a single-tenant (bound) key this is OPTIONAL and derives from the key's binding, naming a different slug is a 403 forbidden. For an operator key it is REQUIRED (omitting it is a 400 tenant_required) and must sit inside the key's allowlist.

Responses

200 The single-use connect ticket, shown exactly once.

200 example
{
  "ok": true,
  "data": {
    "ticket": "string",
    "expires_at": 0,
    "expires_in_ms": 0
  }
}

400 A malformed request. code is invalid_request (bad body/param, or a rejected webhook url with the reason in detail) or tenant_required (operator key omitted ?tenant).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

401 Missing or invalid bearer API key (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The key is not scoped to the named tenant (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

oauth#

Hosted OAuth linking entrypoints for Meta (Facebook Login) and Instagram (Instagram Login). Browser redirect flows authed by a console session at /start and a signed state at /callback, never a bearer. Not part of the consumer /v1 surface.

GET /oauth/meta/start#

Begin hosted Meta OAuth linking (console-session authed; not enveloped)

Starts the hosted Meta OAuth flow (I-501). Authenticated by a CONSOLE SESSION cookie, not a bearer key, the linking tenant is derived server-side from the caller's membership (a multi-membership user names ?tenant). Signs a CSRF-hardened state (tenant binding + nonce + issued-at) and 302-redirects to Meta's consent dialog. Not part of the /v1 consumer surface. 401 when no valid console session is present.

Auth: cookieAuth operationId metaOAuthStart

Parameters

NameInTypeRequiredDescription
tenantquerystringnoRequired only for a multi-membership console user; must be one they belong to.

Responses

302 Redirect to the Meta consent dialog (Location carries client_id, redirect_uri, signed state, scope).

400 A multi-membership user omitted ?tenant, or named one they cannot reach.

400 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

401 No valid console session, enveloped unauthorized.

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /oauth/meta/callback#

Meta OAuth redirect callback (state-verified; not enveloped)

Meta redirects the browser here after consent (I-501/I-502). VERIFIES the signed state (timing-safe HMAC, <=600s expiry, tenant binding) BEFORE anything else, a tampered/expired/missing state is a 400 oauth_state_invalid and the token exchange is never attempted. On a valid state it exchanges code for a long-lived token, DISCOVERS the granted assets (paginated /me/accounts + instagram_business_account), stashes them in a short-TTL tenant-bound pending item (page tokens encrypted context-less), and 302-redirects back to the console with status=discover and a SIGNED handle (I-502), it links NOTHING automatically. The wizard then reads GET /oauth/meta/assets and links the chosen assets via POST /oauth/meta/select. No token is ever in the URL. Not part of the /v1 consumer surface.

Auth: none operationId metaOAuthCallback

Parameters

NameInTypeRequiredDescription
statequerystringno
codequerystringno
errorquerystringnoSet by Meta when the user denies consent; no exchange is attempted.

Responses

302 Redirect back to the console (status=discover&handle=…|error|denied; no token in the URL).

400 The state was missing, tampered, or expired, oauth_state_invalid. No exchange attempted.

400 example
{
  "ok": false,
  "error": {
    "code": "oauth_state_invalid",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /oauth/meta/assets#

List the discovered Meta assets for a pending selection (I-502)

The console wizard's read of the assets discovered at the callback, for the operator to choose from. Authenticated by a CONSOLE SESSION whose membership must include the handle's tenant (else 403 forbidden) AND a valid signed handle (else 400 oauth_state_invalid). Returns asset NAMES/ids/usernames only, NEVER a token. 404 pending_link_not_found when the pending session has expired or been consumed.

Auth: cookieAuth operationId metaOAuthAssets

Parameters

NameInTypeRequiredDescription
handlequerystringyesThe signed discovery handle from the callback redirect.

Responses

200 The discovered assets (names/ids only; no tokens).

200 example
{
  "ok": true,
  "data": {
    "assets": [
      {
        "page_id": "string",
        "page_name": "string",
        "instagram": {
          "id": "string",
          "username": "string"
        }
      }
    ]
  }
}

400 The handle was missing, tampered, or expired, oauth_state_invalid.

400 example
{
  "ok": false,
  "error": {
    "code": "oauth_state_invalid",
    "message": "string",
    "detail": "string"
  }
}

403 No console session for the handle's tenant, forbidden.

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 The pending discovery session expired or was consumed, pending_link_not_found.

404 example
{
  "ok": false,
  "error": {
    "code": "pending_link_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

POST /oauth/meta/select#

Link the operator-selected Meta assets (I-502)

Links ONLY the chosen pages (each a messenger account; its instagram_business_account, when present, an instagram account) via the idempotent linkAccount + subscribeWebhooks, re-selecting the same asset overwrites, never duplicates, then consumes the pending item (single-use). Authenticated by a CONSOLE SESSION whose membership includes the handle's tenant AND the signed handle (which doubles as the CSRF defence, so no separate double-submit token is required). Returns the linked accounts NEVER a token.

Auth: cookieAuth operationId metaOAuthSelect

Request body

application/json, required

Request example
{
  "handle": "string",
  "page_ids": [
    "string"
  ]
}

Responses

200 The linked accounts (one per asset per channel; no tokens).

200 example
{
  "ok": true,
  "data": {
    "count": 0,
    "linked": [
      {
        "account_key": "string",
        "platform": "messenger",
        "handle": "string"
      }
    ]
  }
}

400 A malformed body / bad page_ids (invalid_request) or a bad handle (oauth_state_invalid).

400 example
{
  "ok": false,
  "error": {
    "code": "invalid_request",
    "message": "string",
    "detail": "string"
  }
}

403 No console session for the handle's tenant, forbidden.

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

404 The pending discovery session expired or was consumed, pending_link_not_found.

404 example
{
  "ok": false,
  "error": {
    "code": "pending_link_not_found",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /oauth/instagram/start#

Begin hosted Instagram Login onboarding (console-session authed; not enveloped)

Starts the "Instagram API with Instagram Login" flow, a separate Meta product from the Facebook Login flow at /oauth/meta/start, with its own app id and consent host. Authenticated by a console session cookie, never a bearer; the linking tenant is derived server-side from membership (a multi-membership user names ?tenant). Seals the tenant into a signed state and 302-redirects to the Instagram consent dialog with force_reauth=true. Mounted only when an Instagram app id, a state secret and a session key are all configured.

Auth: cookieAuth operationId instagramOAuthStart

Parameters

NameInTypeRequiredDescription
tenantquerystringnoThe tenant slug for a console session. Optional for a single-membership user (it defaults to their one workspace; naming a different one is a 403 forbidden). Required for a multi-membership user (omitting it is a 400 tenant_required) and must be one they belong to.

Responses

302 Redirect to the Instagram consent dialog (Location carries client_id, redirect_uri, scope and the signed state).

400 A multi-membership user omitted ?tenant (tenant_required).

400 example
{
  "ok": false,
  "error": {
    "code": "tenant_required",
    "message": "string",
    "detail": "string"
  }
}

401 No valid console session (unauthorized).

401 example
{
  "ok": false,
  "error": {
    "code": "unauthorized",
    "message": "string",
    "detail": "string"
  }
}

403 The named ?tenant is not one the caller belongs to (forbidden).

403 example
{
  "ok": false,
  "error": {
    "code": "forbidden",
    "message": "string",
    "detail": "string"
  }
}

Error codes

GET /oauth/instagram/callback#

Instagram Login redirect callback (state-verified; not enveloped)

Instagram redirects the browser here after consent. An error parameter (the user denied) is a 302 back to the console with status=denied and no exchange. Otherwise the signed state is verified before anything else; a missing, tampered or expired state is a 400 oauth_state_invalid and the exchange is never attempted. A valid state without a code is a 302 with status=error. On success the code is exchanged for a long-lived Instagram user token below the handler (encrypted, never returned), the one account is linked and subscribed, and the browser is sent back with status=linked; an exchange or link failure is status=error. No token ever appears in a URL or response.

Auth: none operationId instagramOAuthCallback

Parameters

NameInTypeRequiredDescription
statequerystringno
codequerystringno
errorquerystringnoSet by Instagram when the user denies consent; no exchange is attempted.

Responses

302 Redirect back to the console with status=linked, status=denied or status=error; never a token.

400 The state was missing, tampered or expired (oauth_state_invalid). No exchange attempted.

400 example
{
  "ok": false,
  "error": {
    "code": "oauth_state_invalid",
    "message": "string",
    "detail": "string"
  }
}

Error codes

meta-ingest#

Meta-facing ingest webhook. NOT part of the consumer /v1 surface and not enveloped, documented here only so the whole HTTP surface is accounted for. Meta authenticates with X-Hub-Signature-256, so these routes sit outside the /v1 bearer guard.

GET /webhooks/meta#

Meta subscription verification handshake (not enveloped)

Meta calls this when the callback URL is saved and on re-verification. Echoes hub.challenge when hub.verify_token matches; otherwise 403. Plain text, not the /v1 envelope. Not part of the consumer surface.

Auth: none operationId metaVerify

Parameters

NameInTypeRequiredDescription
hub.modequerystringno
hub.verify_tokenquerystringno
hub.challengequerystringno

Responses

200 The echoed challenge.

200 example
string

403 verify_token mismatch.

403 example
forbidden

POST /webhooks/meta#

Meta message ingest (signature-authenticated, not enveloped)

Receives Meta's webhook payloads. Authenticated by X-Hub-Signature-256 over the exact bytes, not a bearer token. Always answers 200 on a signed payload (Meta retries non-2xx), 401 on a bad signature. Plain text, not the /v1 envelope. Not part of the consumer surface.

Auth: none operationId metaIngest

Parameters

NameInTypeRequiredDescription
X-Hub-Signature-256headerstringyesHMAC-SHA256 of the raw body under a Meta app secret.

Request body

application/json, required

Request example
{}

Responses

200 Signed payload acknowledged (stored even if unprocessable).

200 example
ok

401 Signature verification failed.

401 example
unauthorized

meta#

Meta app-level callbacks required for App Review (deauthorize, data deletion, deletion status). Authenticated by the signed_request HMAC, never a bearer, and not enveloped. Not part of the consumer /v1 surface.

POST /deauthorize#

Meta deauthorize callback (signed_request authenticated; not enveloped)

Meta posts here when a user removes the app. Authenticated by the HMAC in the form field signed_request, verified against every configured app secret (Facebook and Instagram), never a bearer. Enqueues erasure of the user's linked accounts and acknowledges immediately with a plain-text ok; Meta ignores the body. A missing or badly signed signed_request is a plain-text 400 so a misconfiguration is visible rather than silently accepted. No CORS. Not part of the consumer surface.

Auth: none operationId metaDeauthorize

Request body

application/x-www-form-urlencoded, required

Request example
{
  "signed_request": "string"
}

Responses

200 Acknowledged; erasure enqueued.

200 example
ok

400 The signed_request is missing, malformed or signed with a foreign secret.

400 example
invalid signed_request

POST /data-deletion#

Meta data-deletion request callback (signed_request authenticated; not enveloped)

Meta posts here when a user requests deletion of their data. Authenticated by the signed_request HMAC exactly like /deauthorize. Records a pending deletion request keyed by a fresh confirmation code, enqueues the erasure, and returns synchronously the exact { url, confirmation_code } shape Meta requires, where url is the public status page below. The erase job flips the request to complete once the cascade finishes. No CORS. Not part of the consumer surface.

Auth: none operationId metaDataDeletion

Request body

application/x-www-form-urlencoded, required

Request example
{
  "signed_request": "string"
}

Responses

200 The request was recorded and erasure enqueued.

200 example
{
  "url": "https://example.com",
  "confirmation_code": "string"
}

400 The signed_request is missing, malformed or signed with a foreign secret.

400 example
{
  "error": "invalid signed_request"
}

GET /data-deletion/status#

Data-deletion request status (public; not enveloped)

The status page Meta's url points at. Public, no credential. Returns only the code, status and request time, never the Meta user id or any account data. complete attests that the authoriser's accounts and their durable data (accounts, tokens, conversations, messages, delivered event copies and send attempts) are erased; the shared raw ingest buffer expires on its own bounded TTL. An unknown or missing code is a 404.

Auth: none operationId metaDataDeletionStatus

Parameters

NameInTypeRequiredDescription
codequerystringyesThe confirmation code from the deletion response.

Responses

200 The request's status.

200 example
{
  "code": "string",
  "status": "pending",
  "requested_at": 0
}

404 No deletion request under that code.

404 example
{
  "error": "not_found"
}

Schemas#

ErrorCode#

One of: unauthorized invalid_token forbidden csrf_failed tenant_required invalid_request tenant_slug_taken member_not_found owner_immutable invite_not_found oauth_state_invalid pending_link_not_found conversation_not_found message_not_found endpoint_not_found key_not_found delivery_not_found replay_conflict window_closed human_agent_unavailable missing_actor rate_limited send_failed

ErrorEnvelope#

PropertyTypeRequiredDescription
okenum: falseyes
errorobjectyes

DiscoveredAsset#

One Meta asset discovered during hosted OAuth (I-502), for the wizard to select. Carries NAMES/ids only, never a token. A Page links as a Messenger account; its instagram_business_account, when present, as an Instagram account.

PropertyTypeRequiredDescription
page_idstringyesThe Facebook Page id (also the selection id).
page_namestringyes
instagramobject | nullyes

CreatedApiKey#

A freshly minted API key. secret is the plaintext, shown exactly once.

PropertyTypeRequiredDescription
idstringyesThe key id (a ULID).
prefixstringyesA non-secret display prefix, e.g. hookchat_live_ab12cd….
secretstringyesThe plaintext key, shown exactly once. Never re-fetchable.
scopeenum: live | testyes
labelstringyes
created_atstring (date-time)yes

ApiKeySummary#

A listed API key, prefix only, never a secret.

PropertyTypeRequiredDescription
idstringyes
prefixstringyes
scopeenum: live | testyes
labelstringyes
statusenum: active | revokedyes
created_atstring (date-time)yes

Window#

The computed messaging window. expires_at is a snake_case ISO-8601 timestamp and is ABSENT when state is closed.

PropertyTypeRequiredDescription
stateenum: open_24h | human_agent_only | closedyes
expires_atstring (date-time)noPresent only when the window is open_24h or human_agent_only.

Conversation#

PropertyTypeRequiredDescription
idstringyes
tenant_idstringyes
account_handlestringyes
platformstringyes
participant_idstringyes
participant_handlestring | nullyes
last_inbound_atstring (date-time) | nullyes
last_outbound_atstring (date-time) | nullyes
last_message_textstring | nullyes
windowobjectyesThe computed messaging window. expires_at is a snake_case ISO-8601 timestamp and is ABSENT when state is closed.
unansweredbooleanyesThe participant spoke last and we have not answered.
can_replybooleanyesTrue only when the 24-hour window is open.
can_send_as_human_agentbooleanyesTrue while the window is not closed (open_24h or human_agent_only).

ConversationMessage#

One message in a conversation's thread (I-201). A narrow projection identity, direction, text and timing only. The raw Meta payload and every credential field stay on the stored item and never cross this boundary.

PropertyTypeRequiredDescription
idstringyesThe platform's own message id (mid).
directionstringyesinbound (from the participant), echo (our send, mirrored back) or outbound.
textstring | nullyesThe message text, or null for an attachment-only message.
sent_atstring (date-time)yesThe message-scoped timestamp (ISO).
sent_viastring | nullyesHow the message reached us / left us (e.g. api_in_window, native_app), or null.
actor_idstring | nullyesThe human actor for a human-agent send, or null.

Account#

PropertyTypeRequiredDescription
account_keystringyes
platformstringyes
external_idstringyes
handlestringyes
display_namestring | nullyes
page_idstring | nullyes
statusenum: active | disabled | erroryes
refresh_errorstring | nullyesThe last token-refresh failure the job recorded, or null when healthy.
last_refreshed_atstring (date-time) | nullyesISO timestamp of the last successful refresh, or null.

AuditEntry#

PropertyTypeRequiredDescription
idstringyes
actionstringyes
resourcestringyes
actorstringyesThe API key id or console user id that performed the action.
ipstring | nullyes
atstring (date-time)yesServer-minted ISO timestamp of the action.

Stats#

The dashboard overview counts (I-207). All bounded reads; the delivery counts + dlq_total are over the last window_hours (default 24), and dlq_total is the sum of the per-endpoint dlq counts within it.

PropertyTypeRequiredDescription
messages_24hintegeryesMessages across the tenant in the last 24h.
dlq_totalintegeryesSum of the per-endpoint dlq counts within window_hours.
window_hoursintegeryesThe window (hours) the delivery counts + dlq_total cover.
endpointsobject[]yesOne row per LIVE endpoint (tombstoned endpoints excluded).

Endpoint#

Public projection of a webhook endpoint, never carries a secret. Timestamps are snake_case ISO-8601, consistent with every other resource.

PropertyTypeRequiredDescription
idstringyes
urlstring (uri)yes
eventsstring[]yesEvent-type subscription filter. Empty means all events.
statusenum: active | paused | deletedyes
signing_secret_prefixstring | nullyesA display prefix of the current secret (never the secret).
secondary_activebooleanyesTrue while a rotated-out secondary secret is still within its overlap.
secondary_expires_atstring (date-time) | nullyesISO-8601 when the rotated-out secondary stops verifying, or null.
created_atstring (date-time)yes
updated_atstring (date-time)yes

SendAttachment#

One media attachment on an outbound send (I-403). Meta pulls the asset from url; type is Meta's attachment type.

PropertyTypeRequiredDescription
typestringyesMeta attachment type, image, video, audio or file.
urlstring (uri)yesA publicly fetchable URL Meta pulls the asset from.

ReplyRequest#

text is required unless attachments is present, a media-only reply is valid. A send with attachments spends the media rate budget; the 24-hour window rules are identical to a text reply.

PropertyTypeRequiredDescription
conversation_idstringyes
textstringno
attachmentsobject[]no
reply_tostringnoOptional platform message id to reply to (Messenger/Instagram message.reply_to.mid). A documented no-op where the platform does not support replies, the send still proceeds. Never affects the window, rate or policy decision.

HumanAgentRequest#

text is required unless attachments is present, a media-only send is valid. A send with attachments spends the media rate budget; the 24h–7d human-agent window rules are identical to a text send.

PropertyTypeRequiredDescription
conversation_idstringyes
textstringno
actor_idstringyesThe human who sent the message. Required; omitting it is a 409 missing_actor.
attachmentsobject[]no
reply_tostringnoOptional platform message id to reply to (Messenger/Instagram message.reply_to.mid). A documented no-op where the platform does not support replies, the send still proceeds. Never affects the window, rate or policy decision.

CreateWebhookRequest#

PropertyTypeRequiredDescription
urlstring (uri)yes
eventsstring[]noOptional subscription filter. Omitted means all events.

PatchWebhookRequest#

Any subset of the fields; an empty body just bumps updated_at.

PropertyTypeRequiredDescription
urlstring (uri)no
eventsstring[]no
statusenum: active | pausednoPause/resume only. Delete is its own route.

DeliveryStatus#

A delivery's ledger state. pending (claimed, due now), attempting (a receiver holds the lease), retrying (a retryable failure, due later), and the terminals delivered, failed (non-retryable), blocked (SSRF or redirect block) and dlq (attempt budget exhausted, replayable).

One of: pending attempting retrying delivered failed blocked dlq

DeliverySummary#

The consumer-safe view of one delivery (one event to one endpoint). No body, no url, never a secret. Timestamps are epoch milliseconds.

PropertyTypeRequiredDescription
delivery_idstringyes
event_idstringyesThe event id the consumer dedupes on.
event_typestringyes
statusenum: pending | attempting | retrying | delivered | failed | blocked | dlqyesA delivery's ledger state. pending (claimed, due now), attempting (a receiver holds the lease), retrying (a retryable failure, due later), and the terminals delivered, failed (non-retryable), blocked (SSRF or redirect block) and dlq (attempt budget exhausted, replayable).
attempt_numberintegeryesAttempts made so far; 0 after a claim or replay.
next_retry_atintegeryesEpoch milliseconds when the next attempt is due.
last_response_statusinteger | nullyesThe endpoint's last HTTP status, or null before any response.
created_atintegeryesEpoch milliseconds (the event's logical time).
updated_atintegeryesEpoch milliseconds of the last ledger change.

ReplayResult#

The single-replay outcome, the delivery id and its post-reset status.

PropertyTypeRequiredDescription
delivery_idstringyes
statusenum: pendingyesAlways pending after a successful reset.

BulkReplayRequest#

The JSON-body form of the bulk replay parameters. Any query-string value with the same name takes precedence.

PropertyTypeRequiredDescription
fromstring | integernoWindow start, ISO 8601 string or epoch milliseconds.
tostring | integernoWindow end, ISO 8601 string or epoch milliseconds. Defaults to now.
statusstring | (enum: dlq | failed | blocked | delivered)[]noSubset of dlq,failed,blocked,delivered, as a comma-separated string or an array. Defaults to dlq,failed.
forcebooleannoMust be true to include delivered.

BulkReplayResult#

The bulk-replay counts for this page of the window.

PropertyTypeRequiredDescription
matchedintegeryesDeliveries in the window with a selected status.
enqueuedintegeryesMatches reset and re-enqueued.
skippedintegeryesMatches left alone (mid-attempt).
next_cursorstring | nullyesOpaque cursor to resume the window from, or null when this page was the last.

TestResult#

The test-send outcome, the new delivery id (the send runs asynchronously).

PropertyTypeRequiredDescription
delivery_idstringyes

RealtimeTicket#

A single-use, short-lived, tenant-bound WebSocket connect ticket (I-309). Passed in the WebSocket URL's query string and consumed at connect. Never echoes the tenant or the credential it was minted with.

PropertyTypeRequiredDescription
ticketstringyesThe plaintext ticket, shown exactly once.
expires_atintegeryesEpoch milliseconds when the ticket stops being accepted.
expires_in_msintegeryesMilliseconds until expires_at, clamped at 0.

ConsoleTenantMembership#

One workspace the console user belongs to, with their role.

PropertyTypeRequiredDescription
slugstringyes
roleenum: owner | admin | memberyes

TeamMember#

PropertyTypeRequiredDescription
user_idstringyes
emailstring (email)yes
roleenum: owner | admin | memberyes
statusenum: activeyes

TeamInvite#

PropertyTypeRequiredDescription
emailstring (email)yes
roleenum: admin | memberyes
statusenum: pendingyes

MetaSignedRequestForm#

Meta's signed_request form field, <base64url(HMAC-SHA256)>.<base64url(payload)>, signed with the app secret. The payload carries the Meta user_id.

PropertyTypeRequiredDescription
signed_requeststringyes