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.dev Production

Authentication#

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

cookieAuth (apiKey in cookie (console_session)): The console session: 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.

404 example
{
  "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.

404 example
{
  "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.

200 example
{
  "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.

200 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of conversations.

200 example
{
  "ok": true,
  "data": {
    "conversations": [
      {
        "id": "string",
        "tenant_id": "string",
        "account_handle": "string",
        "platform": "instagram",
        "participant_id": "string",
        "participant_handle": "string",
        "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe conversation id (the head key CONV#<tenant>#<platform>#<external_id>#<participant_id>). It contains # separators and must be URL-encoded in the path.
tenantquerystringnoThe tenant slug: 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.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.
include_testquerybooleannoInclude 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such conversation (conversation_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe conversation id (the head key CONV#<tenant>#<platform>#<external_id>#<participant_id>). It contains # separators and must be URL-encoded in the path.
tenantquerystringnoThe tenant slug: 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.

200 example
{
  "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.

202 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such conversation (conversation_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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).

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
account_keypathstringyesThe account key from GET /v1/accounts, such as instagram#17841400000000001. URL-encode the # as %23.
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such account in this workspace (account_not_found). A key from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of audit entries.

200 example
{
  "ok": true,
  "data": {
    "audit": [
      {
        "id": "string",
        "action": "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.
conversation_idquerystringnoRestrict to a single conversation (its head id, URL-encoded).
directionqueryenum: inbound | outbound | echonoRestrict 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_testquerybooleannoInclude messages written by test-scope keys. Off by default, so a live view never mixes in test traffic.

Responses

200 A page of messages.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe platform's own message id (mid).
tenantquerystringnoThe 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_testquerybooleannoInclude messages written by test-scope keys. Off by default, so a live view never mixes in test traffic.

Responses

200 The message.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such message (message_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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.

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

Responses

200 The message was sent.

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 Missing or invalid bearer API key (unauthorized).

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

404 No such conversation (conversation_not_found). An id from another workspace is a 404, never a 403.

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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.

502 example
{
  "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.

Request example
{
  "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.

202 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

404 No such conversation (conversation_not_found). An id from another workspace is a 404, never a 403.

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

409 A policy refusal. code is missing_actor (no actor_id), human_agent_unavailable (past 7 days, or no inbound message yet; the message says which) or rate_limited.

409 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
hold_idpathstringyesThe hold id from POST /v1/messages/human-agent.

Responses

200 The hold and where it is.

200 example
{
  "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).

401 example
{
  "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.

404 example
{
  "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.
429 example
{
  "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.

Request example
{
  "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.

201 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

404 No such conversation (conversation_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.

413 example
{
  "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).

415 example
{
  "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.
429 example
{
  "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.

Request example
{
  "upload_id": "string"
}

Responses

202 The file passed the checks and is on its way to Meta. Read the upload until it is ready.

202 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

404 example
{
  "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.

413 example
{
  "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).

415 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
upload_idpathstringyesThe upload id from POST /v1/attachments/uploads.

Responses

200 The upload and where it is.

200 example
{
  "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).

401 example
{
  "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.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
hold_idpathstringyesThe hold id, the hold parameter of verify_url.

Request body

application/json, required

The secret from `verify_url`.

Request example
{
  "secret": "string"
}

Responses

200 The hold as the check page shows it.

200 example
{
  "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).

400 example
{
  "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.

404 example
{
  "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.

409 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
hold_idpathstringyesThe hold id, the hold parameter of verify_url.

Request body

application/json, required

The secret from `verify_url`.

Request example
{
  "secret": "string"
}

Responses

200 The challenge.

200 example
{
  "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).

400 example
{
  "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.

404 example
{
  "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.

409 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
hold_idpathstringyesThe hold id, the hold parameter of verify_url.

Request body

application/json, required

The secret from `verify_url`, and the solved challenge.

Request example
{
  "secret": "string",
  "cap_token": "string",
  "solutions": [
    0
  ]
}

Responses

200 The hold, sent or failed.

200 example
{
  "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).

400 example
{
  "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.

404 example
{
  "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.

409 example
{
  "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.

422 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

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

Responses

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

200 example
{
  "ok": true,
  "data": {
    "endpoint": {
      "id": "string",
      "url": "https://example.com",
      "events": [
        "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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).

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.

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

Responses

200 The endpoint (never carries a secret).

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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).

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from an earlier response's next_cursor. A malformed cursor is a 400 invalid_request, never a 500.

Responses

200 A page of delivery summaries.

200 example
{
  "ok": true,
  "data": {
    "deliveries": [
      {
        "delivery_id": "string",
        "event_id": "string",
        "event_type": "message.received",
        "status": "pending",
        "attempt_number": 0,
        "next_retry_at": 0,
        "last_response_status": 0,
        "created_at": 0,
        "updated_at": 0,
        "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.
sincequerystringnoStart 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.
statusquerystringnoComma-separated subset of dlq,failed,blocked,retrying. Defaults to all four. Any other value is a 400 invalid_request.

Responses

200 The failing deliveries in the window, newest first.

200 example
{
  "ok": true,
  "data": {
    "deliveries": [
      {
        "delivery_id": "string",
        "event_id": "string",
        "event_type": "message.received",
        "status": "pending",
        "attempt_number": 0,
        "next_retry_at": 0,
        "last_response_status": 0,
        "created_at": 0,
        "updated_at": 0,
        "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.
fromquerystringnoWindow start, ISO 8601 or epoch milliseconds; epoch seconds (10 digits) are refused with a 400. Required here or in the body.
toquerystringnoWindow end, ISO 8601 or epoch milliseconds; epoch seconds are refused. Defaults to now.
statusquerystringnoComma-separated subset of dlq,failed,blocked,delivered. Defaults to dlq,failed.
forcequeryenum: truenoMust be the literal true to replay a delivered delivery (or to include delivered in a bulk status set). Any other value is treated as absent.
limitqueryinteger (1..100)noPage size, 1..100 (default 50). A too-large value is clamped to 100; a non-numeric or < 1 value is a 400 invalid_request.
cursorquerystringnoOpaque pagination cursor from 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.

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

Responses

200 The batch outcome for this page.

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

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

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

409 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
deliveryIdpathstringyesThe delivery id (deterministic per event and endpoint).
tenantquerystringnoThe 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.
forcequeryenum: truenoMust be the literal true to replay a delivered delivery (or to include delivered in a bulk status set). Any other value is treated as absent.

Responses

200 The delivery was reset and queued again.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found), or no such delivery under it (delivery_not_found). An id from another workspace is a 404, never a 403.

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

409 Replay refused. replay_conflict: the delivery already succeeded and force was not given (a forced re-send makes 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.

409 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe webhook endpoint id (a ULID).
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such (or tombstoned) endpoint (endpoint_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

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

Responses

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

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

NameInTypeRequiredDescription
idpathstringyesThe API key id (a ULID).
tenantquerystringnoThe 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.

200 example
{
  "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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

404 No such key under this tenant (key_not_found). An id from another workspace is a 404, never a 403.

404 example
{
  "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.
429 example
{
  "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.

200 example
{
  "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).

401 example
{
  "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

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

Responses

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

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 No valid console session cookie (unauthorized).

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

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

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

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.

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

Responses

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

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

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

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

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

  • Retry-After: Seconds until the window resets.
429 example
{
  "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

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

Responses

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

  • Location: The console origin plus the validated post-login path.
  • Set-Cookie: Two cookies, console_session (HttpOnly) and console_csrf (readable).

400 No token (invalid_request).

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

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

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

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

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

Request body

application/json, optional

The single-use sign-in token from the emailed link.

Request example
{
  "token": "string"
}

Responses

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

  • Set-Cookie: Two cookies, console_session (HttpOnly) and console_csrf (readable).
200 example
{
  "ok": true,
  "data": {
    "redirect": "string",
    "csrf": "string"
  }
}

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

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

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

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

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).
200 example
{
  "ok": true,
  "data": {
    "loggedOut": true
  }
}

401 No valid console session cookie (unauthorized).

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

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

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

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.

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

401 No valid console session cookie (unauthorized).

401 example
{
  "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.

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

Responses

200 The workspace was created.

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

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

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

401 No valid console session cookie (unauthorized).

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

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

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

409 The slug is already claimed (tenant_slug_taken).

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

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

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.

Request body

application/json, required

The workspace slug, typed again to confirm.

Request example
{
  "confirm": "string"
}

Responses

202 The deletion is under way, or finished.

202 example
{
  "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).

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

401 No valid console session cookie (unauthorized).

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

403 The caller is not the owner of this workspace (forbidden), or the CSRF token is missing or wrong (csrf_failed).

403 example
{
  "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

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.

Responses

200 The active members and pending invites.

200 example
{
  "ok": true,
  "data": {
    "members": [
      {
        "user_id": "string",
        "email": "user@example.com",
        "role": "owner",
        "status": "active"
      }
    ],
    "invites": [
      {
        "email": "user@example.com",
        "role": "admin",
        "status": "pending",
        "expires_at": "2026-09-03T00:00:00Z"
      }
    ]
  }
}

401 No valid console session cookie (unauthorized).

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

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

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

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

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

Responses

200 The member was removed.

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

401 No valid console session cookie (unauthorized).

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

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

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

404 No such member of this workspace (member_not_found).

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

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

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

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

NameInTypeRequiredDescription
slugpathstringyesThe workspace (tenant) slug.

Request body

application/json, required

The email address to invite and the role the invite grants.

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

Responses

200 The invite was recorded.

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

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

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

401 No valid console session cookie (unauthorized).

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

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

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

409 The address is already a member of this workspace (already_member).

409 example
{
  "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

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

Responses

200 The invite was revoked.

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

401 No valid console session cookie (unauthorized).

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

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

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

404 No such pending invite (invite_not_found).

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

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

NameInTypeRequiredDescription
tenantquerystringnoThe 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.

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

400 A malformed request. code is invalid_request (bad body/param, 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).

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

401 Missing or invalid bearer API key (unauthorized).

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

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

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

429 This API key has used its 600 requests for the current minute.

  • Retry-After: Seconds until the next minute.
429 example
{
  "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

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

Responses

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

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

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

401 No valid console session, enveloped unauthorized.

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

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

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

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

NameInTypeRequiredDescription
statequerystringnoThe signed state the start route issued. Ties the callback to the sign-in that began it.
codequerystringnoThe one-time code Meta returns, exchanged for a token server-side and never logged.
errorquerystringnoSet 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.

400 example
{
  "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

NameInTypeRequiredDescription
handlequerystringyesThe signed connect-session handle from the callback redirect.

Responses

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

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

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

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

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

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

404 The connect session expired or was already used, pending_link_not_found.

404 example
{
  "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.

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

Responses

200 The linked Messenger accounts, one per Page (no tokens).

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

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

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

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

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

404 The connect session expired or was already used, pending_link_not_found.

404 example
{
  "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

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

Responses

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

  • Location: The Instagram consent dialog URL.

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

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

401 No valid console session (unauthorized).

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

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

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

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

NameInTypeRequiredDescription
statequerystringnoThe signed state the start route issued. Ties the callback to the sign-in that began it.
codequerystringnoThe one-time code Meta returns, exchanged for a token server-side and never logged.
errorquerystringnoSet by Instagram when the user denies consent; no exchange is attempted.

Responses

302 Redirect back to the console with status=linked, status=denied or status=error (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.

400 example
{
  "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

NameInTypeRequiredDescription
go-getqueryenum: 1no1 from the go command.

Responses

200 The go-import page (?go-get=1).

200 example
<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

NameInTypeRequiredDescription
go-getqueryenum: 1no1 from the go command.

Responses

200 The go-import page (?go-get=1).

200 example
<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

NameInTypeRequiredDescription
go-getqueryenum: 1no1 from the go command.

Responses

200 The go-import page (?go-get=1).

200 example
<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

NameInTypeRequiredDescription
go-getqueryenum: 1no1 from the go command.

Responses

200 The go-import page (?go-get=1).

200 example
<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

NameInTypeRequiredDescription
hub.modequerystringnoAlways subscribe: Meta sends it when it checks the URL.
hub.verify_tokenquerystringnoThe token set in the app's webhook settings. It must match ours.
hub.challengequerystringnoA string Meta expects echoed back exactly.

Responses

200 The echoed challenge.

200 example
1158201444

403 verify_token mismatch.

403 example
forbidden

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 /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

NameInTypeRequiredDescription
X-Hub-Signature-256headerstringyesHMAC-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.

Request example
{}

Responses

200 Signed payload stored and ingested, or malformed JSON stored without ingestion.

200 example
ok

401 Signature verification failed.

401 example
unauthorized

503 Ingestion failed; retry the same payload. Successfully ingested messages are deduplicated on retry.

503 example
ingestion temporarily unavailable

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

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.

Request example
{
  "signed_request": "string"
}

Responses

200 Acknowledged; erasure enqueued.

200 example
ok

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

400 example
invalid signed_request

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

Request example
{
  "signed_request": "string"
}

Responses

200 The request was recorded and erasure enqueued.

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

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

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

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

NameInTypeRequiredDescription
codequerystringyesThe confirmation code from the deletion response.

Responses

200 The request's status, as a page for a browser or as JSON.

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

404 No deletion request under that code, as a page for a browser or as JSON.

404 example
{
  "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.

PropertyTypeRequiredDescription
okenum: falseyesAlways false on an error.
errorobjectyesThe 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.

PropertyTypeRequiredDescription
page_idstringyesThe Facebook Page id (also the selection id).
page_namestringyesThe Facebook Page name, for display.

CreatedApiKey#

A newly created API key. secret is the plaintext, shown exactly once.

PropertyTypeRequiredDescription
idstringyesThe key id (a ULID).
prefixstringyesA non-secret display prefix, e.g. hookchat_live_ab12cd….
secretstringyesThe plaintext key, shown exactly once. Never re-fetchable.
scopeenum: live | testyeslive keys reach real people; test keys never leave HookChat.
labelstringyesThe name you gave the key.
created_atstring (date-time)yesWhen the key was created.

ApiKeySummary#

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

PropertyTypeRequiredDescription
idstringyesThe key id (a ULID).
prefixstringyesA non-secret display prefix, e.g. hookchat_live_ab12cd….
scopeenum: live | testyeslive keys reach real people; test keys never leave HookChat.
labelstringyesThe name you gave the key.
statusenum: active | revokedyesactive, or revoked. A revoked key fails every call.
created_atstring (date-time)yesWhen the key was created.
last_usedstring (date-time) | nullyesWhen 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.

PropertyTypeRequiredDescription
stateenum: open_24h | human_agent_only | closedyesopen_24h: reply freely. human_agent_only: a human may still write, for up to 7 days. closed: no send is allowed.
expires_atstring (date-time)noPresent 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.

PropertyTypeRequiredDescription
idstringyesThe conversation id.
tenant_idstringyesThe workspace the conversation belongs to.
account_handlestringyesThe linked account that received the messages.
platformenum: instagram | messengeryesWhich platform the conversation is on.
participant_idstringyesThe other party, as the platform identifies them.
participant_handlestring | nullyesThe other party's display name, when the platform shares it.
participant_usernamestring | nullyesThe other party's Instagram username, without the @, when Instagram shares it. Always null on Messenger.
participant_picturestring (uri) | nullyesThe 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_profileenum: pending | complete | unavailableyesWhere 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_atstring (date-time) | nullyesWhen the other party last wrote, or null. Null on a thread the business started from the app.
last_outbound_atstring (date-time) | nullyesWhen you last wrote, or null.
last_message_textstring | nullyesThe most recent message, either way, or null.
windowobjectyesThe computed messaging window. expires_at is a snake_case ISO-8601 timestamp and is absent when state is closed.
unansweredbooleanyesThe participant spoke last and we have not answered.
can_replybooleanyesTrue only when the 24 hour window is open.
can_send_as_human_agentbooleanyesTrue while the window is not closed (open_24h or human_agent_only).

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.

PropertyTypeRequiredDescription
conversationinteger (0..1)yesThe conversation itself: the name, picture and last text.
profileinteger (0..1)yesThe stored profile of the person.
messagesinteger (0..)yesMessages both ways.
markersinteger (0..)yesSend and echo markers that point at messages.
deliveriesinteger (0..)yesDelivered copies of events about this conversation, on all your endpoints, with their bodies.
send_recordsinteger (0..)yesSend attempt and send failure records.
uploadsinteger (0..)yesUploads from a computer still waiting for Meta, with their files.
holdsinteger (0..)yesHeld 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.

PropertyTypeRequiredDescription
idstringyesThe platform's own message id (mid).
directionenum: inbound | outbound | echoyesinbound (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.
textstring | nullyesThe message text, or null for an attachment-only message.
sent_atstring (date-time)yesThe message-scoped timestamp (ISO).
sent_viaenum: api_in_window | api_human_agent | native_app | unknown | nullyesHow 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_idstring | nullyesThe human actor for a human-agent send, or null.
attachmentsobject[]yesThe 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.

PropertyTypeRequiredDescription
typestringyesThe 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.
urlstring | nullyesMeta'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.

PropertyTypeRequiredDescription
account_keystringyesThe stable id HookChat uses for the linked account.
platformenum: instagram | messengeryesWhich platform the account is on.
external_idstringyesThe id the platform uses for the account.
handlestringyesThe account username.
display_namestring | nullyesThe account's display name, or null.
page_idstring | nullyesThe Facebook Page behind a Messenger account, or null.
statusenum: active | disabled | erroryesactive: receiving. error: the platform refused the last token refresh, so the account needs a fresh link; refresh_error says why. disabled: switched off.
refresh_errorstring | nullyesThe last token-refresh failure the job recorded, or null when healthy.
last_refreshed_atstring (date-time) | nullyesISO timestamp of the last successful refresh, or null.

AuditEntry#

One recorded change: who did what, to which resource, from where.

PropertyTypeRequiredDescription
idstringyesThe entry id.
actionenum: 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.testyesWhat 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.
resourcestringyesThe id of the thing that changed.
actorstringyesThe API key id or console user id that performed the action.
ipstring | nullyesWhere the call came from, or null.
atstring (date-time)yesServer-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.

PropertyTypeRequiredDescription
messages_24hintegeryesMessages across the workspace in the last window_hours hours, which is 24 today; the name keeps the original window.
dlq_totalintegeryesSum of the per-endpoint dlq counts within window_hours.
window_hoursintegeryesThe window (hours) the delivery counts + dlq_total cover.
endpointsobject[]yesOne row per live endpoint (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.

PropertyTypeRequiredDescription
idstringyesThe endpoint id.
urlstring (uri)yesWhere deliveries go. HTTPS only, public addresses only.
events(enum: message.received | message.sent | message.failed | test.event | account.connected | account.disconnected | participant.updated)[]yesEvent-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).
statusenum: active | paused | deletedyesactive: delivering. paused: not delivering, by you or after repeated failures; events that arrive meanwhile are not kept. deleted: gone.
signing_secret_prefixstring | nullyesA display prefix of the current secret (never the secret).
secondary_activebooleanyesTrue while a rotated-out secondary secret is still within its overlap.
secondary_expires_atstring (date-time) | nullyesISO-8601 when the rotated-out secondary stops verifying, or null.
created_atstring (date-time)yesWhen the endpoint was registered.
updated_atstring (date-time)yesWhen 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.

PropertyTypeRequiredDescription
typeenum: image | video | audio | fileyesMeta attachment type: image, video, audio or file. Anything else is a 400 invalid_request.
urlstring (uri)noA 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_idstring, up to 128 charactersnoThe 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.

PropertyTypeRequiredDescription
conversation_idstringyesThe conversation the file will be sent in. It decides which account takes the file and so which types are allowed.
filenamestring, up to 255 charactersyesThe file name, as it should reach Meta.
content_typestringyesThe media type, such as image/png, with no parameters. The upload form must post the same value.
sizeinteger (1..)yesThe file size in bytes. Over the limit for its kind is a 413 attachment_too_large.
typeenum: image | video | audio | filenoHow 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.

PropertyTypeRequiredDescription
idstringyesThe upload id, upl_ and 26 characters. Finish the upload with it.
urlstring (uri)yesWhere to post the form. It is a storage url, not a HookChat route, and takes no API key.
fieldsobjectyesForm fields to post before the file, as given, each one. They include the storage key, the content type and a signed policy.
methodenum: POSTyesAlways POST.
expires_atstring (date-time)yesWhen the url stops working: 15 minutes after it was handed out.
max_sizeintegeryesThe most bytes the url takes for this kind of file.

FinishUploadRequest#

The upload to finish.

PropertyTypeRequiredDescription
upload_idstringyesThe id from POST /v1/attachments/uploads.

UploadStatus#

An upload and where it is. attachment comes with ready, error with failed.

PropertyTypeRequiredDescription
idstringyesThe upload id.
statusenum: waiting | processing | ready | failedyeswaiting: not finished yet. processing: on its way to Meta. ready: Meta has it. failed: it will not go; see error.
conversation_idstringyesThe conversation the upload is for.
filenamestringyesThe file name given when the upload began.
content_typestringyesThe content type, checked against the file's first bytes.
sizeintegeryesThe real size of the file in bytes, once finished; the size given when the upload began, before.
typeenum: image | video | audio | fileyesHow the file will be sent.
attachmentobjectnoThe file Meta has, when status is ready. Send it by its id.
errorobjectnoWhy 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.

PropertyTypeRequiredDescription
idstringyesMeta's attachment id. Send it as attachment_id. A test key gets a mock_att_ id.
typeenum: image | video | audio | fileyesHow the file will be sent.
conversation_idstringyesThe conversation the upload was for.
filenamestringyesThe file name given when the upload began.
content_typestringyesThe content type, checked against the file's first bytes.
sizeintegeryesThe 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.

PropertyTypeRequiredDescription
conversation_idstringyesThe conversation to reply in.
textstring, up to 2000 charactersnoThe 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.
attachmentsobject[]noThe 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_tostringnoOptional platform message id to reply to (Messenger/Instagram message.reply_to.mid). A documented no-op where the platform does not support replies, the send still proceeds. Never affects the window, rate or policy decision.

HumanAgentRequest#

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.

PropertyTypeRequiredDescription
conversation_idstringyesThe conversation to write in.
textstring, up to 2000 charactersnoThe 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_idstringyesThe human who sent the message. Required; omitting it is a 409 missing_actor.
attachmentsobject[]noThe 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_tostringnoOptional platform message id to reply to (Messenger/Instagram message.reply_to.mid). A documented no-op where the platform does not support replies, the send still proceeds. Never affects the window, rate or policy decision.

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.

PropertyTypeRequiredDescription
idstringyesThe hold id, hold_ and 26 characters.
statusenum: awaiting_verification | sending | sent | failed | expiredyesawaiting_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_idstringyesThe conversation the message is for.
created_atstring (date-time)yesWhen the hold was made.
expires_atstring (date-time)yesWhen the hold stops waiting: 15 minutes after it was made, or the end of the 7 day window if that is sooner.
verify_urlstring (uri)noThe 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_idstringnoThe platform's own id for the sent message, when status is sent.
errorobjectnoWhy the message was not sent, when status is failed.

HoldPreview#

What the check page shows about a waiting hold.

PropertyTypeRequiredDescription
idstringyesThe hold id.
statusenum: awaiting_verificationyesAlways awaiting_verification: a used or expired hold is a 409 here.
expires_atstring (date-time)yesWhen the hold stops waiting.
platformenum: instagram | messengeryesWhere the message goes.
display_namestring | nullnoThe name of the person the message goes to, when known.
textstringnoThe message text. Absent for a media send.
attachmentsinteger (0..)yesHow many files the message carries.

VerifySecretRequest#

The hold's secret.

PropertyTypeRequiredDescription
secretstringyesThe part of verify_url after #t=.

CompleteHoldRequest#

The hold's secret and the solved Cap challenge.

PropertyTypeRequiredDescription
secretstringyesThe part of verify_url after #t=.
cap_tokenstringyesThe token from /verify/holds/{hold_id}/challenge.
solutionsinteger[]yesOne 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.

PropertyTypeRequiredDescription
challengeobjectyesHow many puzzles, how long each salt is, and how hard each one is.
tokenstringyesThe signed challenge. Send it back as cap_token.
expiresintegeryesWhen the challenge stops working, in epoch milliseconds.

CreateWebhookRequest#

What it takes to register an endpoint: the URL and the event types to deliver.

PropertyTypeRequiredDescription
urlstring (uri), up to 2048 charactersyesWhere 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)[]noOptional 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.

PropertyTypeRequiredDescription
urlstring (uri)noA new delivery URL. HTTPS only, public addresses only.
events(enum: message.received | message.sent | message.failed | test.event | account.connected | account.disconnected | participant.updated)[]noThe full list of event types to deliver; replaces the old list. A type outside this list is a 400 invalid_request.
statusenum: active | pausednoPause/resume only. Delete is its own route.

DeliveryStatus#

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

One of: pending attempting retrying delivered failed blocked dlq

DeliverySummary#

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

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

PropertyTypeRequiredDescription
delivery_idstringyesThe delivery that was queued again.
statusenum: pendingyesAlways pending after a successful reset.

BulkReplayRequest#

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

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

BulkReplayResult#

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

PropertyTypeRequiredDescription
matchedintegeryesDeliveries in the window with a selected status.
enqueuedintegeryesMatches reset and queued again.
skippedintegeryesMatches left alone: mid-attempt, or the event body is no longer kept (7 days).
next_cursorstring | nullyesOpaque cursor to resume the window from, or null when this page was the last.

TestResult#

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

PropertyTypeRequiredDescription
delivery_idstringyesThe 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.

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

ConsoleTenantMembership#

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

PropertyTypeRequiredDescription
slugstringyesThe workspace slug.
roleenum: owner | admin | memberyesWhat the member may do: owner, admin or member.
namestring | nullyesThe 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.

PropertyTypeRequiredDescription
user_idstringyesThe member's user id.
emailstring (email)yesThe member's sign-in email.
roleenum: owner | admin | memberyesWhat the member may do: owner, admin or member.
statusenum: activeyesAlways 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.

PropertyTypeRequiredDescription
emailstring (email)yesThe address invited.
roleenum: admin | memberyesThe role the invite grants when accepted.
statusenum: pendingyespending until the address signs in.
expires_atstring (date-time) | nullyesWhen 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.

PropertyTypeRequiredDescription
signed_requeststringyesThe signed_request Meta posts, base64url payload and signature.