API reference
HookChat API 1.0.0: 65 routes in 16 groups. Every /v1 response is one envelope, { ok, data } or { ok: false, error }; branch on ok alone.
Every response, success or failure, carries an x-correlation-id header: the request id. Quote it when you report a problem and the log line is one search away. The SDKs surface it as the error's request id and the CLI prints it under a failed command. One response is not enveloped: a burst past the edge rate limit gets a bare 429 with no ok field and no request id; treat any 429 as rate_limited and back off.
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: 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, with the tenant derived from the user's memberships and csrfHeader enforced on writes. An absent, invalid or expired session is a 401 unauthorized.
csrfHeader (apiKey in header (x-csrf-token)): The signed double-submit CSRF token. The readable console_csrf cookie carries a copy of the token bound into the session JWT; a console-session write (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#
Is the gateway up. No key needed.
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.
Location: The marketing site URL plus the original query string.
404 ROOT_REDIRECT_URL is unset, so no root route is mounted and the gateway answers the way it does for any unknown path: the envelope with route_not_found and the request id.
{
"ok": false,
"error": {
"code": "route_not_found",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /.well-known/security.txt#
Redirect the security contact file to the marketing site
Where to report a security issue, in the standard security.txt format. The file lives on the marketing site; the apex sends a permanent redirect to it. Mounted only when ROOT_REDIRECT_URL is configured, like the root redirect.
Auth: none operationId securityTxt
Responses
301 Permanent redirect to the marketing site's /.well-known/security.txt.
Location: The marketing site's security.txt URL.
404 ROOT_REDIRECT_URL is unset, so the route is not mounted and the gateway answers the envelope with route_not_found.
{
"ok": false,
"error": {
"code": "route_not_found",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /health#
Is the gateway up (no key, no envelope)
Unauthenticated, no CORS, and not the /v1 envelope beyond { ok: true }. Infrastructure checks hit this; your code 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
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/ping#
Is the gateway up, with its version (no key needed)
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
conversations#
Every conversation on your accounts, the newest message either way first, each with its reply window. Guide: Reply within the window.
GET /v1/conversations#
The inbox feed
The conversations on your accounts, ordered by the newest message in either direction, newest first. A message from the participant, a send through the API and a send from the Instagram or Messenger app all move a thread to the top. A thread the business started from the app is listed too: it has no message from the participant yet, so last_inbound_at is null, the window is closed and can_reply is false. Window state and the two "can I send" flags are computed server-side so nobody reimplements Meta's rules. Paginated with an opaque cursor and a limit clamped to 1..100. A conversation is kept while messages keep arriving and expires 7 days after its last message; after that it is gone from this feed.
Auth: bearerAuth operationId listConversations
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The tenant slug: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 an earlier 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",
"participant_username": "ada.inks",
"participant_picture": "https://example.com",
"participant_profile": "complete",
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/conversations/{id}#
One conversation's detail and thread history
Returns the same conversation projection as the list feed (the window state and the two "can I send" flags), plus the thread: its messages, newest first, as a narrow projection with only id, direction, text and timing. Never the raw Meta payload or any credential. An id that does not exist, or belongs to another tenant, is the same 404 conversation_not_found. It is never a 403, which would confirm the id exists elsewhere, and never another tenant's data. Use limit (default 50, maximum 100) and cursor to page through older messages. A filtered page may be empty and still have a next_cursor. The path id contains # separators and must be URL-encoded. Each message is kept 7 days after it was sent, and the conversation 7 days after its last message; an expired one is a 404 conversation_not_found.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500. |
include_test | query | boolean | no | Include messages written by test-scope keys. Off by default, so a live view never mixes in test traffic. |
Responses
200 The conversation and its thread.
{
"ok": true,
"data": {
"next_cursor": "string",
"conversation": {
"id": "string",
"tenant_id": "string",
"account_handle": "string",
"platform": "instagram",
"participant_id": "string",
"participant_handle": "string",
"participant_username": "ada.inks",
"participant_picture": "https://example.com",
"participant_profile": "complete",
"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": "inbound",
"text": "string",
"sent_at": "2026-09-03T00:00:00Z",
"sent_via": "api_in_window",
"actor_id": "string",
"attachments": [
{
"type": "image",
"url": "https://cdn.example.com/media/a.jpg"
}
]
}
]
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "conversation_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
DELETE /v1/conversations/{id}#
Erase one person's data
Erases everything HookChat holds about the person in this conversation, for when your customer asks you to delete their data. A conversation is one person on one of your accounts, so this is the whole request: the conversation, every message both ways, the stored profile, the delivered copies of its events on all your endpoints (with their bodies), its send records, and any upload still waiting for Meta. The answer holds counts, never content. It cannot be undone. A big conversation that does not finish in about 15 seconds is a 202: HookChat finishes it in the background, and calling again later answers 200 when nothing is left. Calling again after it is done is a 200 with zero counts. An id from another workspace, or one that is not well formed, is the same 404 conversation_not_found, never a 403. A test key gets a 403. Writes an audit row that names the conversation by a hash of its id, with the counts. The raw webhook copies Meta sent HookChat are not erased here: they cannot be found by person, and expire 7 days after they arrived. No webhook event is sent. If the person writes again, a new conversation starts.
Auth: bearerAuth operationId eraseConversation
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 Erased. Every count is zero when nothing was held.
{
"ok": true,
"data": {
"done": true,
"removed": {
"conversation": 0,
"profile": 0,
"messages": 0,
"markers": 0,
"deliveries": 0,
"send_records": 0,
"uploads": 0,
"holds": 0
}
}
}202 Erasure started and goes on in the background; the counts are what was removed so far.
{
"ok": true,
"data": {
"done": false,
"removed": {
"conversation": 0,
"profile": 0,
"messages": 0,
"markers": 0,
"deliveries": 0,
"send_records": 0,
"uploads": 0,
"holds": 0
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "conversation_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
accounts#
The Instagram and Messenger accounts connected to your workspace, and whether each one still works. Guide: Connect an Instagram account.
GET /v1/accounts#
The accounts connected to your workspace
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The tenant's accounts (not paginated).
{
"ok": true,
"data": {
"accounts": [
{
"account_key": "string",
"platform": "instagram",
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
DELETE /v1/accounts/{account_key}#
Disconnect an account
Unlinks the account from your workspace. HookChat asks Meta to stop sending the account's webhooks, deletes the stored token, and sends account.disconnected to your endpoints. Conversations and messages stay; connect the same account again and its history comes back. A Facebook Page stays subscribed at Meta while another connected account still uses it. A key from another workspace, or one that is not connected, is a 404 account_not_found, never a 403. A test key gets a 403. Writes an audit row.
Auth: bearerAuth operationId disconnectAccount
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
account_key | path | string | yes | The account key from GET /v1/accounts, such as instagram#17841400000000001. URL-encode the # as %23. |
tenant | query | string | no | The tenant slug: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The account was disconnected.
{
"ok": true,
"data": {
"disconnected": true
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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 account in this workspace (account_not_found). A key from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "account_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
audit#
Who changed what in your workspace, and when. Never message content.
GET /v1/audit#
Who changed what in your workspace
Most-recent-first. 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 an earlier 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": "tenant.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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
stats#
The overview numbers the console shows, in one call. Messages, deliveries and how many gave up.
GET /v1/stats#
The dashboard overview counts
Your workspace's counts for the console overview: messages in the last 24h, each endpoint's delivered, failed and given-up (dlq) counts, and the workspace's dlq total. Every count is bounded, so the call stays fast however long the history grows. The delivery counts and dlq_total cover a recent window (window_hours, default 24), not all delivery history. dlq_total is the sum of the per-endpoint dlq counts within that window. Only live endpoints appear; deleted endpoints are left out.
Auth: bearerAuth operationId getStats
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The tenant slug: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
messages#
The two ways to send, and the only two. A reply inside the window, or a message from a named human after it. Guides: Reply within the window and Send as a human agent.
GET /v1/messages#
Every message across your accounts, newest first
Every message across the tenant's conversations, newest first. Narrow it with 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. Messages from test-scope keys are left out unless include_test is true. Each message is kept 7 days after it was sent, then expires.
Auth: bearerAuth operationId listMessages
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The tenant slug: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 an earlier 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 | echo | no | Restrict to one direction: inbound (from the participant), outbound (sent through the API) or echo (sent from the Instagram or Messenger app, which also reaches your webhook as message.sent). Any other value is a 400 invalid_request. |
include_test | query | boolean | no | Include messages written by test-scope keys. Off by default, so a live view never mixes in test traffic. |
Responses
200 A page of messages.
{
"ok": true,
"data": {
"messages": [
{
"id": "string",
"direction": "inbound",
"text": "string",
"sent_at": "2026-09-03T00:00:00Z",
"sent_via": "api_in_window",
"actor_id": "string",
"attachments": [
{
"type": "image",
"url": "https://cdn.example.com/media/a.jpg"
}
]
}
],
"next_cursor": "string"
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/messages/{id}#
One message by id
A single message by its platform id (mid), scoped to the tenant. 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. A message from a test-scope key is a 404 too unless include_test is true.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
include_test | query | boolean | no | Include messages written by test-scope keys. Off by default, so a live view never mixes in test traffic. |
Responses
200 The message.
{
"ok": true,
"data": {
"message": {
"id": "string",
"direction": "inbound",
"text": "string",
"sent_at": "2026-09-03T00:00:00Z",
"sent_via": "api_in_window",
"actor_id": "string",
"attachments": [
{
"type": "image",
"url": "https://cdn.example.com/media/a.jpg"
}
]
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "message_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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
The conversation to answer, the text or attachments to send, and an optional message id to reply to.
{
"conversation_id": "string",
"text": "string",
"attachments": [
{
"type": "image",
"url": "https://example.com",
"attachment_id": "string"
}
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/messages/human-agent#
Hold a human-typed message until a person checks it, then send it
The only route out of the 24 hour window (24 hours to 7 days), for a message a person typed. Nothing is sent by this call. HookChat checks the send as it would send it, holds the message and answers 202 with a hold. The person who typed it opens verify_url and passes a short check (Cap, a proof of work run in the browser); HookChat then sends the message and the usual message.sent event follows. The check raises the cost of sending by script; it does not prove a person. A hold waits 15 minutes, never past the end of the 7 day window, and then expires without sending. Read GET /v1/messages/holds/{hold_id} for where it is. actor_id is required, the person 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). A test key holds the same way and sends through the mock connection. Body is snake_case; legacy camelCase (conversationId/actorId) is tolerated but undocumented.
Auth: bearerAuth operationId sendAsHumanAgent
Request body
application/json, required
The conversation, the text or attachments, and the id of the person sending it as a human agent.
{
"conversation_id": "string",
"text": "string",
"actor_id": "string",
"attachments": [
{
"type": "image",
"url": "https://example.com",
"attachment_id": "string"
}
],
"reply_to": "string"
}Responses
202 The message is held. Open verify_url to check and send it.
{
"ok": true,
"data": {
"hold": {
"id": "hold_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "awaiting_verification",
"conversation_id": "string",
"created_at": "2026-09-03T00:00:00Z",
"expires_at": "2026-09-03T00:00:00Z",
"verify_url": "https://example.com",
"platform_message_id": "string",
"error": {
"code": "string",
"message": "string",
"detail": "string"
}
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace 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 no inbound message yet; the message says which) or rate_limited.
{
"ok": false,
"error": {
"code": "missing_actor",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/messages/holds/{hold_id}#
Read a held human-agent message
Where a hold is. awaiting_verification: waiting for a person to open verify_url. sending: checked, and HookChat is sending it. sent: Meta has it; platform_message_id is its id. failed: it was not sent, and error says why (the window closed while it waited, the send budget ran out, or Meta refused it). expired: nobody checked it in time, and nothing was sent. The message text is not kept on the hold once it ends. A hold stays readable for 30 minutes from when it began. verify_url is only in the answer that made the hold.
Auth: bearerAuth operationId getHold
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hold_id | path | string | yes | The hold id from POST /v1/messages/human-agent. |
Responses
200 The hold and where it is.
{
"ok": true,
"data": {
"hold": {
"id": "hold_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "awaiting_verification",
"conversation_id": "string",
"created_at": "2026-09-03T00:00:00Z",
"expires_at": "2026-09-03T00:00:00Z",
"verify_url": "https://example.com",
"platform_message_id": "string",
"error": {
"code": "string",
"message": "string",
"detail": "string"
}
}
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}404 No such hold (hold_not_found): the id is unknown, it belongs to another workspace, the secret is wrong, or the hold is gone. The answer is the same for each.
{
"ok": false,
"error": {
"code": "hold_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
attachments#
Send a file from your computer. Ask for an upload url, post the file to it, then finish the upload: HookChat checks it at once and hands it to Meta in the background. Read the upload until it is ready, then send its attachment id. HookChat deletes its copy once Meta has it.
POST /v1/attachments/uploads#
Ask for an upload url
Checks the conversation, the type and the size, then hands out a url and form fields to post the file to. The url works for 15 minutes. Post every field in fields, then the file as file, in one multipart form; storage refuses a file larger than max_size or of another content type. Then call POST /v1/attachments with the upload id, and read GET /v1/attachments/uploads/{upload_id} until it is ready. HookChat keeps the file only until Meta has it, and never longer than a day. The limits are Meta's: an image is png, jpeg or gif (gif not on an Instagram Login account) up to 8 MB; a video is mp4, mov, webm, ogg or avi and audio is aac, m4a, wav or mp4, each up to 25 MB; a file is a PDF up to 25 MB on Instagram, or any type but a program on Messenger. A test key works the same way.
Auth: bearerAuth operationId createUpload
Request body
application/json, required
The conversation the file is for, its name, its content type and its size in bytes.
{
"conversation_id": "string",
"filename": "string",
"content_type": "image/png",
"size": 1,
"type": "image"
}Responses
201 Where to post the file, and the fields to post with it.
{
"ok": true,
"data": {
"upload": {
"id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"url": "https://example.com",
"fields": {},
"method": "POST",
"expires_at": "2026-09-03T00:00:00Z",
"max_size": 0
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "conversation_not_found",
"message": "string",
"detail": "string"
}
}413 The file is over Meta's limit for its kind (attachment_too_large): 8 MB for an image, 25 MB for a video, audio or file. The message names the limit.
{
"ok": false,
"error": {
"code": "attachment_too_large",
"message": "string",
"detail": "string"
}
}415 The account does not take this content type as this kind, or the file's first bytes are not the type it claims (unsupported_media_type).
{
"ok": false,
"error": {
"code": "unsupported_media_type",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/attachments#
Finish an upload
Checks the file you posted, its real size and that its first bytes match its content type, then queues it for Meta and answers 202 at once with the upload processing. Meta can take a minute to take a large video, so HookChat hands the file over in the background: read GET /v1/attachments/uploads/{upload_id} every second or two until its status is ready, then send upload.attachment.id as attachment_id in the attachments of POST /v1/messages/reply or POST /v1/messages/human-agent. An upload finishes once: after a 413 or a 415 the file is deleted, so ask for a new upload. Finishing before the file is posted is a 404 that uses nothing up; post the file and try again. A test key gets a mock_att_ id and nothing reaches Meta.
Auth: bearerAuth operationId finishUpload
Request body
application/json, required
The id of the upload to finish.
{
"upload_id": "string"
}Responses
202 The file passed the checks and is on its way to Meta. Read the upload until it is ready.
{
"ok": true,
"data": {
"upload": {
"id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "waiting",
"conversation_id": "string",
"filename": "string",
"content_type": "string",
"size": 0,
"type": "image",
"attachment": {
"id": "string",
"type": "image"
},
"error": {
"code": "string",
"message": "string",
"detail": "string"
}
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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 upload_not_found: no such upload, or it expired, was used already, belongs to another workspace, or has no file posted yet (the message says which of the last two). conversation_not_found: the conversation or its account went away since the upload began.
{
"ok": false,
"error": {
"code": "upload_not_found",
"message": "string",
"detail": "string"
}
}413 The file is over Meta's limit for its kind (attachment_too_large): 8 MB for an image, 25 MB for a video, audio or file. The message names the limit.
{
"ok": false,
"error": {
"code": "attachment_too_large",
"message": "string",
"detail": "string"
}
}415 The account does not take this content type as this kind, or the file's first bytes are not the type it claims (unsupported_media_type).
{
"ok": false,
"error": {
"code": "unsupported_media_type",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/attachments/uploads/{upload_id}#
Read an upload
Where an upload is. waiting: the url was handed out and the upload is not finished yet. processing: finished, and HookChat is handing the file to Meta. ready: Meta has it; send attachment.id as attachment_id. failed: it will not go, and error says why: send_failed when Meta refused the file (error.detail is Meta's reason), internal_error when HookChat could not hand it over, conversation_not_found when the account was unlinked meanwhile. Ask for a new upload after a failure. An upload stays readable for a day from when it began.
Auth: bearerAuth operationId getUpload
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
upload_id | path | string | yes | The upload id from POST /v1/attachments/uploads. |
Responses
200 The upload and where it is.
{
"ok": true,
"data": {
"upload": {
"id": "upl_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "waiting",
"conversation_id": "string",
"filename": "string",
"content_type": "string",
"size": 0,
"type": "image",
"attachment": {
"id": "string",
"type": "image"
},
"error": {
"code": "string",
"message": "string",
"detail": "string"
}
}
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}404 upload_not_found: no such upload, or it expired, or it belongs to another workspace. The answer is the same for each.
{
"ok": false,
"error": {
"code": "upload_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
verify#
The check page behind a hold's verify_url. It takes the hold's secret, never an API key, and runs a Cap proof of work in the browser before the held message is sent. Build your own page on these routes, or send people to HookChat's.
POST /verify/holds/{hold_id}#
Read a hold for the check page
What the check page shows: the message, the conversation's name and when the hold expires. It takes the secret from verify_url (the part after #t=), never an API key. A wrong secret, an unknown hold and a hold gone past its 30 minutes all answer the same 404; a hold that was used or expired is a 409 hold_expired.
Auth: none operationId readHoldToVerify
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hold_id | path | string | yes | The hold id, the hold parameter of verify_url. |
Request body
application/json, required
The secret from `verify_url`.
{
"secret": "string"
}Responses
200 The hold as the check page shows it.
{
"ok": true,
"data": {
"hold": {
"id": "string",
"status": "awaiting_verification",
"expires_at": "2026-09-03T00:00:00Z",
"platform": "instagram",
"display_name": "string",
"text": "string",
"attachments": 0
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}404 No such hold (hold_not_found): the id is unknown, it belongs to another workspace, the secret is wrong, or the hold is gone. The answer is the same for each.
{
"ok": false,
"error": {
"code": "hold_not_found",
"message": "string",
"detail": "string"
}
}409 The hold can no longer be checked (hold_expired): it was sent or failed already, or its 15 minutes ran out. The message says which. Nothing more will be sent from it; make a new hold.
{
"ok": false,
"error": {
"code": "hold_expired",
"message": "string",
"detail": "string"
}
}429 Too many tries from this address, or on this hold, in the last few minutes (rate_limited).
Retry-After: Seconds to wait before trying again.
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /verify/holds/{hold_id}/challenge#
Get a Cap challenge for a hold
A Cap proof of work challenge bound to this hold. The Cap widget solves it in the browser; post its token and solutions to /verify/holds/{hold_id}/complete. A challenge works for 10 minutes or until the hold expires, whichever is sooner.
Auth: none operationId holdChallenge
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hold_id | path | string | yes | The hold id, the hold parameter of verify_url. |
Request body
application/json, required
The secret from `verify_url`.
{
"secret": "string"
}Responses
200 The challenge.
{
"ok": true,
"data": {
"challenge": {
"c": 0,
"s": 0,
"d": 0
},
"token": "string",
"expires": 0
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}404 No such hold (hold_not_found): the id is unknown, it belongs to another workspace, the secret is wrong, or the hold is gone. The answer is the same for each.
{
"ok": false,
"error": {
"code": "hold_not_found",
"message": "string",
"detail": "string"
}
}409 The hold can no longer be checked (hold_expired): it was sent or failed already, or its 15 minutes ran out. The message says which. Nothing more will be sent from it; make a new hold.
{
"ok": false,
"error": {
"code": "hold_expired",
"message": "string",
"detail": "string"
}
}429 Too many tries from this address, or on this hold, in the last few minutes (rate_limited).
Retry-After: Seconds to wait before trying again.
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /verify/holds/{hold_id}/complete#
Pass the check and send the held message
Checks the Cap solution, then sends the held message in the same request. The window is worked out again at this moment, so a window that closed while the hold waited ends it as failed. Answers 200 with the hold, sent or failed. A hold is sent once: a second complete, or one after the hold expired, is a 409 hold_expired. A wrong solution is a 422 verification_failed and the hold keeps waiting, so the person can try again.
Auth: none operationId completeHold
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hold_id | path | string | yes | The hold id, the hold parameter of verify_url. |
Request body
application/json, required
The secret from `verify_url`, and the solved challenge.
{
"secret": "string",
"cap_token": "string",
"solutions": [
0
]
}Responses
200 The hold, sent or failed.
{
"ok": true,
"data": {
"hold": {
"id": "hold_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "awaiting_verification",
"conversation_id": "string",
"created_at": "2026-09-03T00:00:00Z",
"expires_at": "2026-09-03T00:00:00Z",
"verify_url": "https://example.com",
"platform_message_id": "string",
"error": {
"code": "string",
"message": "string",
"detail": "string"
}
}
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}404 No such hold (hold_not_found): the id is unknown, it belongs to another workspace, the secret is wrong, or the hold is gone. The answer is the same for each.
{
"ok": false,
"error": {
"code": "hold_not_found",
"message": "string",
"detail": "string"
}
}409 The hold can no longer be checked (hold_expired): it was sent or failed already, or its 15 minutes ran out. The message says which. Nothing more will be sent from it; make a new hold.
{
"ok": false,
"error": {
"code": "hold_expired",
"message": "string",
"detail": "string"
}
}422 The check did not pass (verification_failed): the Cap solution is wrong, belongs to another hold, or its challenge expired. The hold keeps waiting; get a new challenge and try again.
{
"ok": false,
"error": {
"code": "verification_failed",
"message": "string",
"detail": "string"
}
}429 Too many tries from this address, or on this hold, in the last few minutes (rate_limited).
Retry-After: Seconds to wait before trying again.
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
webhooks#
Your endpoints: add one, pause it, rotate its secret, read its delivery log and replay what failed. Guides: Receive and verify events, Rotate a webhook secret, Inspect and replay deliveries and Send test events.
GET /v1/webhooks#
List webhook endpoints (never returns a secret)
Lists the workspace's endpoints, newest first, with their status and events. Never returns a signing secret; use the id with the deliveries routes to see what each endpoint received.
Auth: bearerAuth operationId listWebhooks
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant | query | string | no | The tenant slug: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The tenant's live endpoints.
{
"ok": true,
"data": {
"endpoints": [
{
"id": "string",
"url": "https://example.com",
"events": [
"message.received"
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/webhooks#
Register a webhook endpoint (secret shown once)
Creates an endpoint and 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Request body
application/json, required
The HTTPS URL to deliver events to, and which event types to send.
{
"url": "https://example.com",
"events": [
"message.received"
]
}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": [
"message.received"
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/webhooks/{id}#
Fetch one endpoint (never returns a secret)
Returns one endpoint by id: URL, events, status and the secret prefix for display. The signing secret itself is shown once at creation or rotation and never here.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The endpoint (never carries a secret).
{
"ok": true,
"data": {
"endpoint": {
"id": "string",
"url": "https://example.com",
"events": [
"message.received"
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Request body
application/json, required
The fields to change: the URL, the event filter, or the status.
{
"url": "https://example.com",
"events": [
"message.received"
],
"status": "active"
}Responses
200 The endpoint (never carries a secret).
{
"ok": true,
"data": {
"endpoint": {
"id": "string",
"url": "https://example.com",
"events": [
"message.received"
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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. Deleting the same endpoint again answers 200 again and writes no second audit row. Only an unknown id is a 404.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 Deleted.
{
"ok": true,
"data": {
"deleted": true
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/webhooks/{id}/rotate#
Rotate the signing secret (new secret shown once)
Issues 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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": [
"message.received"
],
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /v1/webhooks/{id}/deliveries#
List an endpoint's deliveries, newest first
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 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 unknown or deleted endpoint id is a 404 endpoint_not_found, like every other endpoint route. Paginated with the opaque cursor and a limit clamped to 1..100. Rows are kept for 90 days and then expire, but the event body each one carried is kept only 7 days (body_expires_at), so a replay works only within 7 days.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 an earlier 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,
"body_expires_at": 0
}
],
"next_cursor": "string"
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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. An unknown or deleted endpoint id is a 404 endpoint_not_found.
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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
since | query | string | no | Start of the window, ISO 8601 or epoch milliseconds; a value in epoch seconds (10 digits) is refused with a 400. Defaults to 24 hours ago and must not be in the future. 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,
"body_expires_at": 0
}
],
"truncated": true
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/webhooks/{id}/deliveries/replay#
Bulk replay the deliveries in a time and status window
A bounded batch retry, safe to run again, 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 safe. Including delivered is refused with a 409 replay_conflict unless force is true, because a forced re-send makes your receiver see a duplicate event id (which is why dedupe-on-id exists). Each match is reset to a fresh attempt budget and queued again; a delivery mid-attempt, or one whose event body is no longer kept (bodies are kept 7 days), is counted in skipped, never double-enqueued. A paused endpoint is not delivering, so a replay against one is a 409 replay_conflict: resume it, then replay. 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
from | query | string | no | Window start, ISO 8601 or epoch milliseconds; epoch seconds (10 digits) are refused with a 400. Required here or in the body. |
to | query | string | no | Window end, ISO 8601 or epoch milliseconds; epoch seconds are refused. 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 an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500. |
Request body
application/json, optional
How far back to retry failed deliveries, and whether to force ones that already succeeded.
{
"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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace 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 your receiver see a duplicate event id), or the delivery is currently mid-attempt.
{
"ok": false,
"error": {
"code": "replay_conflict",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay#
Replay one delivery with a fresh attempt budget
Resets the delivery to attempt 0, status pending, due now, and queues it again 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 receiver 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. So is a replay against a paused endpoint, which is not delivering: resume it, then replay. The event body is kept 7 days (the delivery record 90), so a replay after body_expires_at is a 409 replay_expired: there is nothing left to send. 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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 queued again.
{
"ok": true,
"data": {
"delivery_id": "string",
"status": "pending"
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace 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 your receiver see a duplicate event id), or the delivery is currently mid-attempt. replay_expired: the event body is no longer kept (bodies are kept 7 days, the delivery record 90), so there is nothing to send again.
{
"ok": false,
"error": {
"code": "replay_conflict",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/webhooks/{id}/test#
Send a test event through the real delivery path
Builds 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The test event was enqueued.
{
"ok": true,
"data": {
"delivery_id": "string"
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "endpoint_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
keys#
Your API keys. Create one and see it once, list them by prefix, revoke one. Guide: Manage API keys.
GET /v1/keys#
List API keys (prefix only, never a secret)
The tenant's API keys, newest first. 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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",
"last_used": "2026-09-03T00:00:00Z"
}
]
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /v1/keys#
Create an API key (the secret is shown once)
Creates a key bound to the caller's workspace 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 cannot be recovered. 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Request body
application/json, optional
The scope and label of the key to create.
{
"scope": "live",
"label": "string"
}Responses
200 The key was created; 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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
DELETE /v1/keys/{id}#
Revoke an API key
Revokes the key. Safe to repeat: revoking an already revoked key still 200s and writes no second audit row. The revoke is scoped to the caller's tenant, so a key id from another workspace finds no key and is a 404 key_not_found, never a 403 that would confirm the id exists under another tenant. Only 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
Responses
200 The key was revoked.
{
"ok": true,
"data": {
"revoked": true
}
}400 A malformed request. code is invalid_request (bad body/param, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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 key under this tenant (key_not_found). An id from another workspace is a 404, never a 403.
{
"ok": false,
"error": {
"code": "key_not_found",
"message": "string",
"detail": "string"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
console#
The signed-in console's own routes, for the person using it. A session cookie, not an API key.
GET /console/me#
The logged-in console operator's identity
Returns the signed-in console operator's {actor_id, email, tenant, tenants}. It is authenticated by the console session cookie, not a bearer key; an absent, invalid or expired session is a 401 unauthorized. actor_id is console:<userId>, the same stable operator identity every audited console write 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"
],
"workspaces": [
{
"slug": "string",
"name": "string",
"role": "string"
}
]
}
}401 Missing or invalid bearer API key (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /console/session/ws-ticket#
Get a one-time ticket for the console's live updates
The console-session counterpart of POST /v1/realtime/ticket. Authed by the session cookie plus the CSRF header (a POST is a write 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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /console/auth/request#
Request a magic sign-in link
Creates 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 find out which accounts exist. 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
The email address to send a sign-in link to, and where to land after signing in.
{
"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).
Retry-After: Seconds until the window resets.
{
"ok": false,
"error": {
"code": "rate_limited",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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.
Location: The console origin plus the validated post-login path.Set-Cookie: Two cookies,console_session(HttpOnly) andconsole_csrf(readable).
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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 writes. 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
The single-use sign-in token from the emailed link.
{
"token": "string"
}Responses
200 Signed in. The session and CSRF cookies are set.
Set-Cookie: Two cookies,console_session(HttpOnly) andconsole_csrf(readable).
{
"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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /console/auth/logout#
End the console session
Clears the console_session and console_csrf cookies. A CSRF-protected write: the session cookie and a matching x-csrf-token header are both required.
Auth: cookieAuth + csrfHeader operationId consoleAuthLogout
Responses
200 The session was cleared.
Set-Cookie: Both cookies cleared (Max-Age=0).
{
"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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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",
"name": "string"
}
]
}
}401 No valid console session cookie (unauthorized).
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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
The slug and display name of the workspace to create.
{
"slug": "string",
"name": "string"
}Responses
200 The workspace was created.
{
"ok": true,
"data": {
"slug": "string",
"role": "owner",
"name": "string"
}
}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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
DELETE /console/tenants/{slug}#
Delete a workspace and its data
Owner only, console session only, CSRF-protected. An API key is never accepted here, an operator key included: the route reads the session cookie alone. confirm must be the workspace slug, typed exactly. In the request, the workspace is marked as being deleted, every member, pending invite and API key comes off it (an operator key keeps its other workspaces), its webhook endpoints are deleted, and its connected accounts stop taking webhooks and sends and are unsubscribed at Meta. The rest runs in the background: accounts and their tokens, conversations, messages, delivered copies, send records, uploads and held sends, endpoints, keys and memberships. The answer is 202 while that runs. Asking again answers the same 202 with the time of the first ask, or deleted once it is done. The slug cannot be used for a new workspace afterwards. Raw inbound events are not stored per workspace and expire on their own within 7 days. An audit record of the deletion (who, when, the slug and counts) is kept.
Auth: cookieAuth + csrfHeader operationId consoleDeleteTenant
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
slug | path | string | yes | The workspace (tenant) slug. |
Request body
application/json, required
The workspace slug, typed again to confirm.
{
"confirm": "string"
}Responses
202 The deletion is under way, or finished.
{
"ok": true,
"data": {
"slug": "string",
"status": "deleting",
"requested_at": "2026-09-03T00:00:00Z"
}
}400 confirm is missing or is not the workspace slug (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 caller is not the owner of this workspace (forbidden), or the CSRF token is missing or wrong (csrf_failed).
{
"ok": false,
"error": {
"code": "forbidden",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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",
"expires_at": "2026-09-03T00:00:00Z"
}
]
}
}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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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
The email address to invite and the role the invite grants.
{
"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"
}
}409 The address is already a member of this workspace (already_member).
{
"ok": false,
"error": {
"code": "already_member",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
realtime#
A one-time ticket to open the live update socket.
POST /v1/realtime/ticket#
Get a one-time ticket to open the live update socket
A browser cannot set an Authorization header on a WebSocket upgrade, so a short-lived, single-use, workspace-bound ticket is issued here over an authenticated call and passed to the WebSocket endpoint as the ticket query parameter (?ticket=<value>); it is verified and consumed 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: the workspace name you see in the console. A workspace key already knows its workspace, so this is optional and naming a different one is a 403 forbidden. An operator key must name one (omitting it is a 400 tenant_required) and it must be on the key's allowlist. It is 3 to 40 lowercase letters, digits or dashes, starting with a letter and ending with a letter or digit; any other shape is a 400 invalid_request. |
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, a send with both text and attachments, a send that mixes an image with other media or carries more than 10 images, 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"
}
}429 This API key has used its 600 requests for the current minute.
Retry-After: Seconds until the next minute.
{
"ok": false,
"error": {
"code": "unauthorized",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
oauth#
The browser flow that connects a Facebook or Instagram account: the console sends a signed-in person to /start, Meta sends them back to /callback. A console session and a signed state, never an API key. Not part of the /v1 API you call from code.
GET /oauth/meta/start#
Start connecting a Facebook account from the console
Starts the hosted Meta OAuth flow. It is 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 public /v1 API. 401 when no valid console session is present; 400 when a multi-membership user omits ?tenant; 403 when they name one they do not belong to.
Auth: cookieAuth operationId metaOAuthStart
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 Meta consent dialog (Location carries client_id, redirect_uri, signed state, scope).
400 A multi-membership user omitted ?tenant (tenant_required).
{
"ok": false,
"error": {
"code": "tenant_required",
"message": "string",
"detail": "string"
}
}401 No valid console session, enveloped 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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /oauth/meta/callback#
Meta sends the person back after Facebook Login
Meta redirects the browser here after consent. The signed state (timing-safe HMAC, at most 600 seconds old, bound to the tenant) is verified before anything else. A tampered, expired or 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 and discovers the granted Facebook Pages (paginated /me/accounts). This flow is for Messenger only: a Page's Instagram account is not discovered or linked, Instagram connects through GET /oauth/instagram/start. It stashes them in a short-lived, tenant-bound pending item (page tokens encrypted), then redirects back to the console with status=discover and a signed handle. It links nothing on its own. The wizard then reads GET /oauth/meta/assets and links the chosen assets through POST /oauth/meta/select. No token is ever in the URL. Not part of the public /v1 API.
Auth: none operationId metaOAuthCallback
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
state | query | string | no | The signed state the start route issued. Ties the callback to the sign-in that began it. |
code | query | string | no | The one-time code Meta returns, exchanged for a token server-side and never logged. |
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; on error, rid carries the request id to quote; 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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /oauth/meta/assets#
List the discovered Meta assets for a pending selection
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 and 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 connect-session 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"
}
]
}
}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 connect session expired or was already used, pending_link_not_found.
{
"ok": false,
"error": {
"code": "pending_link_not_found",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /oauth/meta/select#
Link the operator-selected Meta assets
Links only the chosen Pages, each as a messenger account, through linkAccount + subscribeWebhooks, both safe to repeat: re-selecting the same asset overwrites, never duplicates. Then it 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
Which discovered Facebook Pages to link for Messenger.
{
"handle": "string",
"page_ids": [
"string"
]
}Responses
200 The linked Messenger accounts, one per Page (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 connect session expired or was already used, pending_link_not_found.
{
"ok": false,
"error": {
"code": "pending_link_not_found",
"message": "string",
"detail": "string"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /oauth/instagram/start#
Start connecting an Instagram account from the console
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).
Location: The Instagram consent dialog URL.
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /oauth/instagram/callback#
Meta sends the person back after Instagram Login
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 | The signed state the start route issued. Ties the callback to the sign-in that began it. |
code | query | string | no | The one-time code Meta returns, exchanged for a token server-side and never logged. |
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 (with rid, the request id to quote); never a token.
Location: The console connect page, with the outcome in the query string.
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"
}
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
go#
The Go import paths hookchat.dev/go and hookchat.dev/cli. The go command reads a go-import tag here and fetches the module from the downloads host, so go get and go install need no account. Not part of the /v1 API and not wrapped in the envelope.
GET /go#
The Go SDK import path, hookchat.dev/go
With ?go-get=1 (what the go command sends), an HTML page whose go-import tag names hookchat.dev/go as a module served with the GOPROXY protocol from the downloads host. Without it, a redirect to the docs.
Auth: none operationId goImportSdk
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
go-get | query | enum: 1 | no | 1 from the go command. |
Responses
200 The go-import page (?go-get=1).
<meta name="go-import" content="hookchat.dev/go mod https://get.hookchat.dev/gomod">302 A browser, sent to the Go SDK docs.
Location: The docs page.
GET /cli#
The CLI module import path, hookchat.dev/cli
With ?go-get=1 (what the go command sends), an HTML page whose go-import tag names hookchat.dev/cli as a module served with the GOPROXY protocol from the downloads host. Without it, a redirect to the docs.
Auth: none operationId goImportCli
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
go-get | query | enum: 1 | no | 1 from the go command. |
Responses
200 The go-import page (?go-get=1).
<meta name="go-import" content="hookchat.dev/cli mod https://get.hookchat.dev/gomod">302 A browser, sent to the CLI docs.
Location: The docs page.
GET /cli/cmd#
A package path under the CLI module
With ?go-get=1 (what the go command sends), an HTML page whose go-import tag names hookchat.dev/cli as a module served with the GOPROXY protocol from the downloads host. Without it, a redirect to the docs.
Auth: none operationId goImportCliCmd
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
go-get | query | enum: 1 | no | 1 from the go command. |
Responses
200 The go-import page (?go-get=1).
<meta name="go-import" content="hookchat.dev/cli mod https://get.hookchat.dev/gomod">302 A browser, sent to the CLI docs.
Location: The docs page.
GET /cli/cmd/hookchat#
The CLI binary, go install hookchat.dev/cli/cmd/hookchat
With ?go-get=1 (what the go command sends), an HTML page whose go-import tag names hookchat.dev/cli as a module served with the GOPROXY protocol from the downloads host. Without it, a redirect to the docs.
Auth: none operationId goImportCliBinary
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
go-get | query | enum: 1 | no | 1 from the go command. |
Responses
200 The go-import page (?go-get=1).
<meta name="go-import" content="hookchat.dev/cli mod https://get.hookchat.dev/gomod">302 A browser, sent to the CLI docs.
Location: The docs page.
meta-ingest#
Where Meta delivers messages to the gateway. Not part of the /v1 API you call from code and not wrapped in the envelope, listed here so every route is accounted for. Meta signs each call with X-Hub-Signature-256, so these routes sit outside the API key check.
GET /webhooks/meta#
Meta checks the webhook is ours
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 public /v1 API.
Auth: none operationId metaVerify
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
hub.mode | query | string | no | Always subscribe: Meta sends it when it checks the URL. |
hub.verify_token | query | string | no | The token set in the app's webhook settings. It must match ours. |
hub.challenge | query | string | no | A string Meta expects echoed back exactly. |
Responses
200 The echoed challenge.
1158201444403 verify_token mismatch.
forbiddenAny other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /webhooks/meta#
Meta delivers messages here
Receives Meta's webhook payloads. Authenticated by X-Hub-Signature-256 over the exact bytes, not a bearer token. Answers 200 after successful ingestion and raw-event storage; malformed signed JSON is stored and acknowledged without ingestion. Ingestion failures return 503 so Meta retries; raw-event storage failures return 5xx. Bad signatures return 401. Plain text on 200/401/503, not the /v1 envelope. Not part of the public /v1 API.
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
The raw Meta webhook payload, signed with X-Hub-Signature-256 and verified over the exact bytes.
{}Responses
200 Signed payload stored and ingested, or malformed JSON stored without ingestion.
ok401 Signature verification failed.
unauthorized503 Ingestion failed; retry the same payload. Successfully ingested messages are deduplicated on retry.
ingestion temporarily unavailableAny other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
meta#
The callbacks Meta requires for App Review: a person removed the app, a person asked for their data to be deleted, and the page that shows how that request is going. Meta signs each call with signed_request, never an API key, and the replies are not wrapped in the envelope. Not part of the /v1 API you call from code. Guide: Data deletion and app removal.
POST /deauthorize#
A person removed the app
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 public /v1 API.
Auth: none operationId metaDeauthorize
Request body
application/x-www-form-urlencoded, required
Meta's signed_request form field, naming the user who removed the app.
{
"signed_request": "string"
}Responses
200 Acknowledged; erasure enqueued.
ok400 The signed_request is missing, malformed or signed with a foreign secret.
invalid signed_requestAny other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
POST /data-deletion#
A person asked Meta to delete their data
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 public /v1 API.
Auth: none operationId metaDataDeletion
Request body
application/x-www-form-urlencoded, required
Meta's signed_request form field, naming the user who asked for deletion.
{
"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"
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
GET /data-deletion/status#
How a data deletion request is going
The status page Meta's url points at. Public, no credential. A browser (an Accept header naming text/html) gets a page in plain words; any other caller gets JSON with 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, as a page for a browser or as JSON.
{
"code": "string",
"status": "pending",
"requested_at": 0
}404 No deletion request under that code, as a page for a browser or as JSON.
{
"error": "not_found"
}Any other status Something went wrong on our side (internal_error, 500). The envelope and the request id are intact. Retry once; if it happens again, write to support with the request id and the time.
Error codes
Schemas#
ErrorCode#
The fixed set of error codes. Match on these, not on the message text.
One of: unauthorized invalid_token forbidden csrf_failed tenant_required invalid_request tenant_slug_taken already_member member_not_found owner_immutable invite_not_found upload_not_found hold_not_found hold_expired verification_failed attachment_too_large unsupported_media_type oauth_state_invalid pending_link_not_found conversation_not_found message_not_found endpoint_not_found route_not_found key_not_found account_not_found delivery_not_found replay_conflict replay_expired window_closed human_agent_unavailable missing_actor rate_limited send_failed internal_error
ErrorEnvelope#
What every failed call returns: ok false and an error with a code, a message and sometimes a detail.
| Property | Type | Required | Description |
|---|---|---|---|
ok | enum: false | yes | Always false on an error. |
error | object | yes | The error: a code from the fixed set, a message, and sometimes a detail. |
DiscoveredAsset#
One Facebook Page discovered during hosted OAuth, for the wizard to select. Carries names and ids only, never a token. A Page links as a Messenger account only; Instagram connects through Instagram Login.
| Property | Type | Required | Description |
|---|---|---|---|
page_id | string | yes | The Facebook Page id (also the selection id). |
page_name | string | yes | The Facebook Page name, for display. |
CreatedApiKey#
A newly created 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 | live keys reach real people; test keys never leave HookChat. |
label | string | yes | The name you gave the key. |
created_at | string (date-time) | yes | When the key was created. |
ApiKeySummary#
A listed API key, prefix only, never a secret.
| 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…. |
scope | enum: live | test | yes | live keys reach real people; test keys never leave HookChat. |
label | string | yes | The name you gave the key. |
status | enum: active | revoked | yes | active, or revoked. A revoked key fails every call. |
created_at | string (date-time) | yes | When the key was created. |
last_used | string (date-time) | null | yes | When the key last authenticated a call, to the nearest five minutes, or null if it never has. |
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 | open_24h: reply freely. human_agent_only: a human may still write, for up to 7 days. closed: no send is allowed. |
expires_at | string (date-time) | no | Present only when the window is open_24h or human_agent_only. |
Conversation#
One thread between a linked account and one other party, with its reply window and the last message either way.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The conversation id. |
tenant_id | string | yes | The workspace the conversation belongs to. |
account_handle | string | yes | The linked account that received the messages. |
platform | enum: instagram | messenger | yes | Which platform the conversation is on. |
participant_id | string | yes | The other party, as the platform identifies them. |
participant_handle | string | null | yes | The other party's display name, when the platform shares it. |
participant_username | string | null | yes | The other party's Instagram username, without the @, when Instagram shares it. Always null on Messenger. |
participant_picture | string (uri) | null | yes | The other party's profile picture URL, as Meta gave it, or null. Meta's picture URLs expire; download the image if you need it later. |
participant_profile | enum: pending | complete | unavailable | yes | Where the lookup of the other party's name, username and picture stands. It runs just after the first message arrives, theirs or one the business sent from the Instagram or Messenger app, not while it is received, so a conversation fetched straight away can still be pending. complete: something was stored, and a participant.updated webhook event went to your endpoints. unavailable: Meta shared nothing; it is tried again on their next message. Guide: Show who you are talking to. |
last_inbound_at | string (date-time) | null | yes | When the other party last wrote, or null. Null on a thread the business started from the app. |
last_outbound_at | string (date-time) | null | yes | When you last wrote, or null. |
last_message_text | string | null | yes | The most recent message, either way, or null. |
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). |
ConversationErasure#
How many of each kind of record this call removed. Counts only, never content. A call after a 202 counts only what that call removed.
| Property | Type | Required | Description |
|---|---|---|---|
conversation | integer (0..1) | yes | The conversation itself: the name, picture and last text. |
profile | integer (0..1) | yes | The stored profile of the person. |
messages | integer (0..) | yes | Messages both ways. |
markers | integer (0..) | yes | Send and echo markers that point at messages. |
deliveries | integer (0..) | yes | Delivered copies of events about this conversation, on all your endpoints, with their bodies. |
send_records | integer (0..) | yes | Send attempt and send failure records. |
uploads | integer (0..) | yes | Uploads from a computer still waiting for Meta, with their files. |
holds | integer (0..) | yes | Held human-agent messages for this conversation, sent or not. |
ConversationMessage#
One message in a conversation's thread. A narrow projection: identity, direction, text, attachments and timing. 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 | enum: inbound | outbound | echo | yes | inbound (from the participant), outbound (a send through the API) or echo (a send from the Instagram or Messenger app). Webhooks deliver outbound and echo both as message.sent. |
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 | enum: api_in_window | api_human_agent | native_app | unknown | null | yes | How the message travelled. api_in_window: sent through the API inside the 24 hour window. api_human_agent: sent through the API as a human agent. native_app: sent from the Instagram or Messenger app. unknown: the platform did not say. Null when it is not known at all. |
actor_id | string | null | yes | The human actor for a human-agent send, or null. |
attachments | object[] | yes | The message's media, each as a type and a link. Always present, and empty when the message has none. HookChat does not keep a copy of the media. |
MessageAttachment#
One media item on a message. Only type and url are carried.
| Property | Type | Required | Description |
|---|---|---|---|
type | string | yes | The kind of media, as Meta names it. Common values are image, video, audio, file, share, story_mention, ig_reel, reel, ig_post, template, fallback and unknown. New values can appear, so treat this as an open list. |
url | string | null | yes | Meta's link to the media. It expires after a while, so fetch it soon if you keep it. |
Account#
A linked Instagram or Messenger account, with its credential health.
| Property | Type | Required | Description |
|---|---|---|---|
account_key | string | yes | The stable id HookChat uses for the linked account. |
platform | enum: instagram | messenger | yes | Which platform the account is on. |
external_id | string | yes | The id the platform uses for the account. |
handle | string | yes | The account username. |
display_name | string | null | yes | The account's display name, or null. |
page_id | string | null | yes | The Facebook Page behind a Messenger account, or null. |
status | enum: active | disabled | error | yes | active: receiving. error: the platform refused the last token refresh, so the account needs a fresh link; refresh_error says why. disabled: switched off. |
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#
One recorded change: who did what, to which resource, from where.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The entry id. |
action | enum: tenant.create | tenant.delete | tenant.deleted | invite.create | invite.claim | invite.revoke | member.remove | realtime.ticket | session.request | session.verify | session.logout | key.rate_limited | key.create | key.revoke | webhook.create | webhook.update | webhook.delete | webhook.pause | webhook.resume | webhook.auto_pause | webhook.rotate | webhook.replay | webhook.bulk_replay | webhook.test | yes | What happened, as resource.verb. tenant.create: a workspace was made. tenant.delete, tenant.deleted: its owner asked for it to be deleted, and the deletion finished. invite.create, invite.claim, invite.revoke: a teammate was invited, accepted, or the invite was withdrawn. member.remove: a teammate was removed. session.request, session.verify, session.logout: a sign-in link was asked for, used, or the session ended. realtime.ticket: a live-socket ticket was issued. key.create, key.revoke: an API key was made or revoked. key.rate_limited: a key hit its request budget. webhook.create, webhook.update, webhook.delete: an endpoint was added, changed or removed. webhook.pause, webhook.resume: an operator paused or resumed it. webhook.auto_pause: the gateway paused it after five failures in a row. webhook.rotate: its signing secret was rotated. webhook.replay, webhook.bulk_replay: one delivery, or every failed one in a window, was sent again. webhook.test: a test event was sent. |
resource | string | yes | The id of the thing that changed. |
actor | string | yes | The API key id or console user id that performed the action. |
ip | string | null | yes | Where the call came from, or null. |
at | string (date-time) | yes | Server-stamped ISO timestamp of the action. |
Stats#
The dashboard overview counts. 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 workspace in the last window_hours hours, which is 24 today; the name keeps the original window. |
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 (deleted endpoints are left out). |
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 | The endpoint id. |
url | string (uri) | yes | Where deliveries go. HTTPS only, public addresses only. |
events | (enum: message.received | message.sent | message.failed | test.event | account.connected | account.disconnected | participant.updated)[] | yes | Event-type subscription filter. Empty means all events. message.sent covers both kinds of send: through the API (direction: outbound) and from the Instagram or Messenger app (direction: echo). |
status | enum: active | paused | deleted | yes | active: delivering. paused: not delivering, by you or after repeated failures; events that arrive meanwhile are not kept. deleted: gone. |
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 | When the endpoint was registered. |
updated_at | string (date-time) | yes | When it last changed. |
SendAttachment#
One media attachment on an outbound send: a type and exactly one of url or attachment_id. Meta fetches a url itself; an attachment_id names a file you uploaded from your computer with POST /v1/attachments/uploads and POST /v1/attachments, once the upload is ready. Both, or neither, is a 400 invalid_request. type is Meta's attachment type. Meta's limits: on Instagram an image is png or jpeg up to 8 MB, a video is mp4, ogg, avi, mov or webm up to 25 MB, audio is aac, m4a, wav or mp4 up to 25 MB, and a file is a PDF up to 25 MB. On Messenger an image is up to 8 MB and a video, audio or file up to 25 MB, and Meta gives up on a video fetch after 75 seconds. Audio and video spend the media rate budget.
| Property | Type | Required | Description |
|---|---|---|---|
type | enum: image | video | audio | file | yes | Meta attachment type: image, video, audio or file. Anything else is a 400 invalid_request. |
url | string (uri) | no | A publicly fetchable https URL Meta pulls the asset from; http or a malformed URL is a 400 invalid_request. Leave it out when you send an attachment_id. |
attachment_id | string, up to 128 characters | no | The attachment.id of a ready upload (GET /v1/attachments/uploads/{upload_id}). Leave url out when you send this. The sent message shows the file's url once Meta confirms the send; until then its url is null. |
CreateUploadRequest#
What HookChat needs to check a file before it is sent anywhere.
| Property | Type | Required | Description |
|---|---|---|---|
conversation_id | string | yes | The conversation the file will be sent in. It decides which account takes the file and so which types are allowed. |
filename | string, up to 255 characters | yes | The file name, as it should reach Meta. |
content_type | string | yes | The media type, such as image/png, with no parameters. The upload form must post the same value. |
size | integer (1..) | yes | The file size in bytes. Over the limit for its kind is a 413 attachment_too_large. |
type | enum: image | video | audio | file | no | How the file will be sent. Optional: left out, it follows the content type (image/*, video/*, audio/*, anything else a file). |
Upload#
Where to post the file. Post every field, then the file as file, as one multipart form.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The upload id, upl_ and 26 characters. Finish the upload with it. |
url | string (uri) | yes | Where to post the form. It is a storage url, not a HookChat route, and takes no API key. |
fields | object | yes | Form fields to post before the file, as given, each one. They include the storage key, the content type and a signed policy. |
method | enum: POST | yes | Always POST. |
expires_at | string (date-time) | yes | When the url stops working: 15 minutes after it was handed out. |
max_size | integer | yes | The most bytes the url takes for this kind of file. |
FinishUploadRequest#
The upload to finish.
| Property | Type | Required | Description |
|---|---|---|---|
upload_id | string | yes | The id from POST /v1/attachments/uploads. |
UploadStatus#
An upload and where it is. attachment comes with ready, error with failed.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The upload id. |
status | enum: waiting | processing | ready | failed | yes | waiting: not finished yet. processing: on its way to Meta. ready: Meta has it. failed: it will not go; see error. |
conversation_id | string | yes | The conversation the upload is for. |
filename | string | yes | The file name given when the upload began. |
content_type | string | yes | The content type, checked against the file's first bytes. |
size | integer | yes | The real size of the file in bytes, once finished; the size given when the upload began, before. |
type | enum: image | video | audio | file | yes | How the file will be sent. |
attachment | object | no | The file Meta has, when status is ready. Send it by its id. |
error | object | no | Why the upload failed, when status is failed. |
UploadedAttachment#
What the SDKs' upload helper gives back once an upload is ready. Send it by its id.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | Meta's attachment id. Send it as attachment_id. A test key gets a mock_att_ id. |
type | enum: image | video | audio | file | yes | How the file will be sent. |
conversation_id | string | yes | The conversation the upload was for. |
filename | string | yes | The file name given when the upload began. |
content_type | string | yes | The content type, checked against the file's first bytes. |
size | integer | yes | The real size of the file, in bytes. |
ReplyRequest#
A reply carries text or attachments, never both: one send is one Meta message, and a Meta message is text or media. To send words and a picture, make two calls. A send with an audio or video attachment spends the media rate budget (an image or a file spends the text one); the 24 hour window rules are identical to a text reply.
| Property | Type | Required | Description |
|---|---|---|---|
conversation_id | string | yes | The conversation to reply in. |
text | string, up to 2000 characters | no | The message text: up to 1000 on Instagram and 2000 on Messenger, the limits Meta sets; longer is a 400 invalid_request. Required when there are no attachments; leave it out when there are. |
attachments | object[] | no | The media to send, in place of text. Up to 10 images go as one message; a video, audio or file goes alone, one per send. Any other mix, more than 10 images, or attachments with a non-empty text is a 400 invalid_request. |
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#
A send carries text or attachments, never both: one send is one Meta message, and a Meta message is text or media. To send words and a picture, make two calls. A send with an audio or video attachment spends the media rate budget (an image or a file spends the text one); the 24 hours to 7 days human-agent window rules are identical to a text send.
| Property | Type | Required | Description |
|---|---|---|---|
conversation_id | string | yes | The conversation to write in. |
text | string, up to 2000 characters | no | The message text: up to 1000 on Instagram and 2000 on Messenger, the limits Meta sets; longer is a 400 invalid_request. Required when there are no attachments; leave it out when there are. |
actor_id | string | yes | The human who sent the message. Required; omitting it is a 409 missing_actor. |
attachments | object[] | no | The media to send, in place of text. Up to 10 images go as one message; a video, audio or file goes alone, one per send. Any other mix, more than 10 images, or attachments with a non-empty text is a 400 invalid_request. |
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. |
Hold#
A human-agent message waiting for a person to pass the check, and where it is. verify_url comes only in the answer that made the hold; platform_message_id comes with sent, error with failed.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The hold id, hold_ and 26 characters. |
status | enum: awaiting_verification | sending | sent | failed | expired | yes | awaiting_verification: waiting for the check. sending: checked, being sent. sent: Meta has it. failed: not sent; see error. expired: nobody checked it in time, nothing was sent. |
conversation_id | string | yes | The conversation the message is for. |
created_at | string (date-time) | yes | When the hold was made. |
expires_at | string (date-time) | yes | When the hold stops waiting: 15 minutes after it was made, or the end of the 7 day window if that is sooner. |
verify_url | string (uri) | no | The page the person who typed the message opens to pass the check. It carries a one-time secret after #t=: treat it like a password, hand it only to that person, and never log it. Only in the answer to POST /v1/messages/human-agent. |
platform_message_id | string | no | The platform's own id for the sent message, when status is sent. |
error | object | no | Why the message was not sent, when status is failed. |
HoldPreview#
What the check page shows about a waiting hold.
| Property | Type | Required | Description |
|---|---|---|---|
id | string | yes | The hold id. |
status | enum: awaiting_verification | yes | Always awaiting_verification: a used or expired hold is a 409 here. |
expires_at | string (date-time) | yes | When the hold stops waiting. |
platform | enum: instagram | messenger | yes | Where the message goes. |
display_name | string | null | no | The name of the person the message goes to, when known. |
text | string | no | The message text. Absent for a media send. |
attachments | integer (0..) | yes | How many files the message carries. |
VerifySecretRequest#
The hold's secret.
| Property | Type | Required | Description |
|---|---|---|---|
secret | string | yes | The part of verify_url after #t=. |
CompleteHoldRequest#
The hold's secret and the solved Cap challenge.
| Property | Type | Required | Description |
|---|---|---|---|
secret | string | yes | The part of verify_url after #t=. |
cap_token | string | yes | The token from /verify/holds/{hold_id}/challenge. |
solutions | integer[] | yes | One number per challenge, in order, as the Cap widget solves them. |
CapChallenge#
A Cap proof of work challenge, in the shape the Cap widget reads.
| Property | Type | Required | Description |
|---|---|---|---|
challenge | object | yes | How many puzzles, how long each salt is, and how hard each one is. |
token | string | yes | The signed challenge. Send it back as cap_token. |
expires | integer | yes | When the challenge stops working, in epoch milliseconds. |
CreateWebhookRequest#
What it takes to register an endpoint: the URL and the event types to deliver.
| Property | Type | Required | Description |
|---|---|---|---|
url | string (uri), up to 2048 characters | yes | Where to deliver. HTTPS only, public addresses only, 2048 characters at most. |
events | (enum: message.received | message.sent | message.failed | test.event | account.connected | account.disconnected | participant.updated)[] | no | Optional subscription filter. Omitted means all events. message.sent covers API sends and sends from the Instagram or Messenger app. A type outside this list is a 400 invalid_request. |
PatchWebhookRequest#
Any subset of the fields; an empty body just bumps updated_at.
| Property | Type | Required | Description |
|---|---|---|---|
url | string (uri) | no | A new delivery URL. HTTPS only, public addresses only. |
events | (enum: message.received | message.sent | message.failed | test.event | account.connected | account.disconnected | participant.updated)[] | no | The full list of event types to deliver; replaces the old list. A type outside this list is a 400 invalid_request. |
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 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 | The delivery id. |
event_id | string | yes | The event id your receiver dedupes on. |
event_type | string | yes | The event this delivery carries. |
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. |
body_expires_at | integer | yes | Epoch milliseconds when the event body stops being kept (7 days). The delivery record is kept 90 days; after this a replay is refused with replay_expired. |
ReplayResult#
The single-replay outcome, the delivery id and its post-reset status.
| Property | Type | Required | Description |
|---|---|---|---|
delivery_id | string | yes | The delivery that was queued again. |
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; epoch seconds (10 digits) are refused with a 400. |
to | string | integer | no | Window end, ISO 8601 string or epoch milliseconds; epoch seconds are refused. 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 queued again. |
skipped | integer | yes | Matches left alone: mid-attempt, or the event body is no longer kept (7 days). |
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 | The delivery the test event was queued as. |
RealtimeTicket#
A single-use, short-lived, tenant-bound WebSocket connect ticket. Passed to the WebSocket endpoint as the ticket query parameter (?ticket=<value>) and consumed at connect. Never echoes the tenant or the credential it was created 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 | The workspace slug. |
role | enum: owner | admin | member | yes | What the member may do: owner, admin or member. |
name | string | null | yes | The display name given when the workspace was created, or null. |
TeamMember#
One person in a workspace, with the role that sets what they may do.
| Property | Type | Required | Description |
|---|---|---|---|
user_id | string | yes | The member's user id. |
email | string (email) | yes | The member's sign-in email. |
role | enum: owner | admin | member | yes | What the member may do: owner, admin or member. |
status | enum: active | yes | Always active; a removed member is gone from the list. |
TeamInvite#
A pending invitation: the address, the role it grants, its status, and when it lapses.
| Property | Type | Required | Description |
|---|---|---|---|
email | string (email) | yes | The address invited. |
role | enum: admin | member | yes | The role the invite grants when accepted. |
status | enum: pending | yes | pending until the address signs in. |
expires_at | string (date-time) | null | yes | When the invite lapses if nobody has signed in with the address: 14 days after it was sent. Null for an invite sent before the lapse was stamped; it does not lapse. |
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 | The signed_request Meta posts, base64url payload and signature. |