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.devProduction
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 Not FoundGET /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.
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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. |
limit | query | integer (1..100) | no | Page size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request. |
cursor | query | string | no | Opaque 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The conversation id (the head key CONV#<tenant>#<platform>#<external_id>#<participant_id>). It contains # separators and MUST be URL-encoded in the path. |
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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. |
limit | query | integer (1..100) | no | Page size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request. |
cursor | query | string | no | Opaque 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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. |
limit | query | integer (1..100) | no | Page size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request. |
cursor | query | string | no | Opaque pagination cursor from a prior response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500. |
conversation_id | query | string | no | Restrict to a single conversation (its head id, URL-encoded). |
direction | query | enum: inbound | outbound | no | Restrict to one direction. Any other value is a 400 invalid_request. |
Responses
200 A page of messages.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The platform's own message id (mid). |
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
{
"conversation_id": "string",
"text": "string",
"attachments": [
{
"type": "string",
"url": "https://example.com"
}
],
"reply_to": "string"
}Responses
200 The message was sent.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"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.
{
"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.
{
"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.
{
"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
{
"conversation_id": "string",
"text": "string",
"actor_id": "string",
"attachments": [
{
"type": "string",
"url": "https://example.com"
}
],
"reply_to": "string"
}Responses
200 The message was sent.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"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.
{
"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.
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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
{
"url": "https://example.com",
"events": [
"string"
]
}Responses
200 The endpoint plus the one-time plaintext signing_secret (create/rotate only).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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
{
"url": "https://example.com",
"events": [
"string"
],
"status": "active"
}Responses
200 The endpoint (never carries a secret).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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. |
limit | query | integer (1..100) | no | Page size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request. |
cursor | query | string | no | Opaque 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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. |
since | query | string | no | Start of the window, ISO 8601 or epoch milliseconds. Defaults to 24 hours ago. Anything else is a 400 invalid_request. |
status | query | string | no | Comma-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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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. |
from | query | string | no | Window start, ISO 8601 or epoch milliseconds. Required here or in the body. |
to | query | string | no | Window end, ISO 8601 or epoch milliseconds. Defaults to now. |
status | query | string | no | Comma-separated subset of dlq,failed,blocked,delivered. Defaults to dlq,failed. |
force | query | enum: true | no | Must 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. |
limit | query | integer (1..100) | no | Page size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request. |
cursor | query | string | no | Opaque 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
{
"from": "string",
"to": "string",
"status": "string",
"force": true
}Responses
200 The batch outcome for this page.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
deliveryId | path | string | yes | The delivery id (deterministic per event and endpoint). |
tenant | query | string | no | The 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. |
force | query | enum: true | no | Must 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The webhook endpoint id (a ULID). |
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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
{
"scope": "live",
"label": "string"
}Responses
200 The key was minted; secret is shown exactly once.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The API key id (a ULID). |
tenant | query | string | no | The 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.
{
"ok": true,
"data": {
"revoked": true
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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.
{
"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.
{
"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).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"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
{
"email": "user@example.com",
"redirect": "/inbox"
}Responses
200 The request was accepted (whether or not the address is known).
{
"ok": true,
"data": {
"sent": true
}
}400 Not a JSON body, or email is missing or not an email (invalid_request).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}429 The per-email or per-IP sign-in cap was exceeded (rate_limited).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
token | query | string | yes | The 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).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
token | query | string | no | The sign-in token; takes precedence over the body. |
Request body
application/json, optional
{
"token": "string"
}Responses
200 Signed in. The session and CSRF cookies are set.
{
"ok": true,
"data": {
"redirect": "string",
"csrf": "string"
}
}400 No token in the query or body (invalid_request).
{
"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.
{
"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.
{
"ok": true,
"data": {
"loggedOut": true
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"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.
{
"ok": true,
"data": {
"tenants": [
{
"slug": "string",
"role": "owner"
}
]
}
}401 No valid console session cookie (unauthorized).
{
"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
{
"slug": "string",
"name": "string"
}Responses
200 The workspace was created.
{
"ok": true,
"data": {
"slug": "string",
"role": "owner"
}
}400 Not a JSON body, or the slug is malformed or reserved (invalid_request).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"ok": false,
"error": {
"code": "csrf_failed",
"message": "string",
"detail": "string"
}
}409 The slug is already claimed (tenant_slug_taken).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | The workspace (tenant) slug. |
Responses
200 The active members and pending invites.
{
"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).
{
"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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | The workspace (tenant) slug. |
userId | path | string | yes | The console user id of the member to remove. |
Responses
200 The member was removed.
{
"ok": true,
"data": {
"removed": true
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"ok": false,
"error": {
"code": "forbidden",
"message": "string",
"detail": "string"
}
}404 No such member of this workspace (member_not_found).
{
"ok": false,
"error": {
"code": "member_not_found",
"message": "string",
"detail": "string"
}
}409 The target is an owner and cannot be removed (owner_immutable).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | The workspace (tenant) slug. |
Request body
application/json, required
{
"email": "user@example.com",
"role": "member"
}Responses
200 The invite was recorded.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | The workspace (tenant) slug. |
email | path | string (email) | yes | The invited address, URL-encoded once. |
Responses
200 The invite was revoked.
{
"ok": true,
"data": {
"revoked": true
}
}401 No valid console session cookie (unauthorized).
{
"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).
{
"ok": false,
"error": {
"code": "forbidden",
"message": "string",
"detail": "string"
}
}404 No such pending invite (invite_not_found).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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.
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The key is not scoped to the named tenant (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | Required 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.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}401 No valid console session, enveloped unauthorized.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
state | query | string | no | |
code | query | string | no | |
error | query | string | no | Set 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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
handle | query | string | yes | The signed discovery handle from the callback redirect. |
Responses
200 The discovered assets (names/ids only; no tokens).
{
"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.
{
"ok": false,
"error": {
"code": "oauth_state_invalid",
"message": "string",
"detail": "string"
}
}403 No console session for the handle's tenant, forbidden.
{
"ok": false,
"error": {
"code": "forbidden",
"message": "string",
"detail": "string"
}
}404 The pending discovery session expired or was consumed, pending_link_not_found.
{
"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
{
"handle": "string",
"page_ids": [
"string"
]
}Responses
200 The linked accounts (one per asset per channel; no tokens).
{
"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).
{
"ok": false,
"error": {
"code": "invalid_request",
"message": "string",
"detail": "string"
}
}403 No console session for the handle's tenant, forbidden.
{
"ok": false,
"error": {
"code": "forbidden",
"message": "string",
"detail": "string"
}
}404 The pending discovery session expired or was consumed, pending_link_not_found.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The 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).
{
"ok": false,
"error": {
"code": "tenant_required",
"message": "string",
"detail": "string"
}
}401 No valid console session (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}403 The named ?tenant is not one the caller belongs to (forbidden).
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
state | query | string | no | |
code | query | string | no | |
error | query | string | no | Set 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.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hub.mode | query | string | no | |
hub.verify_token | query | string | no | |
hub.challenge | query | string | no |
Responses
200 The echoed challenge.
string403 verify_token mismatch.
forbiddenPOST /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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
X-Hub-Signature-256 | header | string | yes | HMAC-SHA256 of the raw body under a Meta app secret. |
Request body
application/json, required
{}Responses
200 Signed payload acknowledged (stored even if unprocessable).
ok401 Signature verification failed.
unauthorizedmeta#
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
{
"signed_request": "string"
}Responses
200 Acknowledged; erasure enqueued.
ok400 The signed_request is missing, malformed or signed with a foreign secret.
invalid signed_requestPOST /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
{
"signed_request": "string"
}Responses
200 The request was recorded and erasure enqueued.
{
"url": "https://example.com",
"confirmation_code": "string"
}400 The signed_request is missing, malformed or signed with a foreign secret.
{
"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
| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | yes | The confirmation code from the deletion response. |
Responses
200 The request's status.
{
"code": "string",
"status": "pending",
"requested_at": 0
}404 No deletion request under that code.
{
"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#
| Property | Type | Required | Description |
|---|---|---|---|
ok | enum: false | yes | |
error | object | yes |
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.
| Property | Type | Required | Description |
|---|---|---|---|
page_id | string | yes | The Facebook Page id (also the selection id). |
page_name | string | yes | |
instagram | object | null | yes |
CreatedApiKey#
A freshly minted API key. secret is the plaintext, shown exactly once.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The key id (a ULID). |
prefix | string | yes | A non-secret display prefix, e.g. hookchat_live_ab12cd…. |
secret | string | yes | The plaintext key, shown exactly once. Never re-fetchable. |
scope | enum: live | test | yes | |
label | string | yes | |
created_at | string (date-time) | yes |
ApiKeySummary#
A listed API key, prefix only, never a secret.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
prefix | string | yes | |
scope | enum: live | test | yes | |
label | string | yes | |
status | enum: active | revoked | yes | |
created_at | string (date-time) | yes |
Window#
The computed messaging window. expires_at is a snake_case ISO-8601 timestamp and is ABSENT when state is closed.
| Property | Type | Required | Description |
|---|---|---|---|
state | enum: open_24h | human_agent_only | closed | yes | |
expires_at | string (date-time) | no | Present only when the window is open_24h or human_agent_only. |
Conversation#
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
tenant_id | string | yes | |
account_handle | string | yes | |
platform | string | yes | |
participant_id | string | yes | |
participant_handle | string | null | yes | |
last_inbound_at | string (date-time) | null | yes | |
last_outbound_at | string (date-time) | null | yes | |
last_message_text | string | null | yes | |
window | object | yes | The computed messaging window. expires_at is a snake_case ISO-8601 timestamp and is ABSENT when state is closed. |
unanswered | boolean | yes | The participant spoke last and we have not answered. |
can_reply | boolean | yes | True only when the 24-hour window is open. |
can_send_as_human_agent | boolean | yes | True 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.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The platform's own message id (mid). |
direction | string | yes | inbound (from the participant), echo (our send, mirrored back) or outbound. |
text | string | null | yes | The message text, or null for an attachment-only message. |
sent_at | string (date-time) | yes | The message-scoped timestamp (ISO). |
sent_via | string | null | yes | How the message reached us / left us (e.g. api_in_window, native_app), or null. |
actor_id | string | null | yes | The human actor for a human-agent send, or null. |
Account#
| Property | Type | Required | Description |
|---|---|---|---|
account_key | string | yes | |
platform | string | yes | |
external_id | string | yes | |
handle | string | yes | |
display_name | string | null | yes | |
page_id | string | null | yes | |
status | enum: active | disabled | error | yes | |
refresh_error | string | null | yes | The last token-refresh failure the job recorded, or null when healthy. |
last_refreshed_at | string (date-time) | null | yes | ISO timestamp of the last successful refresh, or null. |
AuditEntry#
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
action | string | yes | |
resource | string | yes | |
actor | string | yes | The API key id or console user id that performed the action. |
ip | string | null | yes | |
at | string (date-time) | yes | Server-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.
| Property | Type | Required | Description |
|---|---|---|---|
messages_24h | integer | yes | Messages across the tenant in the last 24h. |
dlq_total | integer | yes | Sum of the per-endpoint dlq counts within window_hours. |
window_hours | integer | yes | The window (hours) the delivery counts + dlq_total cover. |
endpoints | object[] | yes | One 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.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | |
url | string (uri) | yes | |
events | string[] | yes | Event-type subscription filter. Empty means all events. |
status | enum: active | paused | deleted | yes | |
signing_secret_prefix | string | null | yes | A display prefix of the current secret (never the secret). |
secondary_active | boolean | yes | True while a rotated-out secondary secret is still within its overlap. |
secondary_expires_at | string (date-time) | null | yes | ISO-8601 when the rotated-out secondary stops verifying, or null. |
created_at | string (date-time) | yes | |
updated_at | string (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.
| Property | Type | Required | Description |
|---|---|---|---|
type | string | yes | Meta attachment type, image, video, audio or file. |
url | string (uri) | yes | A 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.
| Property | Type | Required | Description |
|---|---|---|---|
conversation_id | string | yes | |
text | string | no | |
attachments | object[] | no | |
reply_to | string | no | Optional 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.
| Property | Type | Required | Description |
|---|---|---|---|
conversation_id | string | yes | |
text | string | no | |
actor_id | string | yes | The human who sent the message. Required; omitting it is a 409 missing_actor. |
attachments | object[] | no | |
reply_to | string | no | Optional 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#
| Property | Type | Required | Description |
|---|---|---|---|
url | string (uri) | yes | |
events | string[] | no | Optional subscription filter. Omitted means all events. |
PatchWebhookRequest#
Any subset of the fields; an empty body just bumps updated_at.
| Property | Type | Required | Description |
|---|---|---|---|
url | string (uri) | no | |
events | string[] | no | |
status | enum: active | paused | no | Pause/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.
| Property | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes | |
event_id | string | yes | The event id the consumer dedupes on. |
event_type | string | yes | |
status | enum: pending | attempting | retrying | delivered | failed | blocked | dlq | yes | 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). |
attempt_number | integer | yes | Attempts made so far; 0 after a claim or replay. |
next_retry_at | integer | yes | Epoch milliseconds when the next attempt is due. |
last_response_status | integer | null | yes | The endpoint's last HTTP status, or null before any response. |
created_at | integer | yes | Epoch milliseconds (the event's logical time). |
updated_at | integer | yes | Epoch milliseconds of the last ledger change. |
ReplayResult#
The single-replay outcome, the delivery id and its post-reset status.
| Property | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes | |
status | enum: pending | yes | Always 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.
| Property | Type | Required | Description |
|---|---|---|---|
from | string | integer | no | Window start, ISO 8601 string or epoch milliseconds. |
to | string | integer | no | Window end, ISO 8601 string or epoch milliseconds. Defaults to now. |
status | string | (enum: dlq | failed | blocked | delivered)[] | no | Subset of dlq,failed,blocked,delivered, as a comma-separated string or an array. Defaults to dlq,failed. |
force | boolean | no | Must be true to include delivered. |
BulkReplayResult#
The bulk-replay counts for this page of the window.
| Property | Type | Required | Description |
|---|---|---|---|
matched | integer | yes | Deliveries in the window with a selected status. |
enqueued | integer | yes | Matches reset and re-enqueued. |
skipped | integer | yes | Matches left alone (mid-attempt). |
next_cursor | string | null | yes | Opaque 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).
| Property | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes |
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.
| Property | Type | Required | Description |
|---|---|---|---|
ticket | string | yes | The plaintext ticket, shown exactly once. |
expires_at | integer | yes | Epoch milliseconds when the ticket stops being accepted. |
expires_in_ms | integer | yes | Milliseconds until expires_at, clamped at 0. |
ConsoleTenantMembership#
One workspace the console user belongs to, with their role.
| Property | Type | Required | Description |
|---|---|---|---|
slug | string | yes | |
role | enum: owner | admin | member | yes |
TeamMember#
| Property | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | |
email | string (email) | yes | |
role | enum: owner | admin | member | yes | |
status | enum: active | yes |
TeamInvite#
| Property | Type | Required | Description |
|---|---|---|---|
email | string (email) | yes | |
role | enum: admin | member | yes | |
status | enum: pending | yes |
MetaSignedRequestForm#
Meta's signed_request form field, <base64url(HMAC-SHA256)>.<base64url(payload)>, signed with the app secret. The payload carries the Meta user_id.
| Property | Type | Required | Description |
|---|---|---|---|
signed_request | string | yes |