Troubleshooting
Every failure HookChat returns carries a stable machine code. This page lists each one with what caused it and what to do, followed by the problems that show up without an error envelope: a signature that will not verify, a browser blocked by CORS, a sign-in email that never arrives.
The error envelope#
Every /v1 response is one envelope. ok is the only field to branch on; on failure error.code is a token from the closed vocabulary below, error.message is for humans and may change, and error.detail appears only where a handler has extra non-sensitive context. Message content never appears in an error.
{
"ok": false,
"error": {
"code": "window_closed",
"message": "the 24h reply window is closed",
"detail": "human agent only"
}
}The SDKs map the envelope onto a typed error: HookChatError.code in TypeScript, an exception class per family in Python (WindowClosedError, NotFoundError and so on), and *hookchat.Error with Code in Go. Every response also carries an x-correlation-id header, surfaced as requestId; quote it when you report a problem.
Error codes#
The vocabulary is closed: only codes a handler actually returns are listed, and adding one is a minor version. Codes marked console only never appear on /v1.
unauthorized#
HTTP 401. The bearer API key is missing, malformed, revoked or unknown. On /console/* routes it means there is no valid session cookie.
Fix. Send Authorization: Bearer hookchat_live_... (or hookchat_test_...) on every /v1 call. Check the key is not revoked with hookchat keys list from a working key. In the CLI this is exit 3; run hookchat auth status to see which key source is active.
invalid_token#
HTTP 401. The console sign-in link token is invalid, expired or already used. Every consume failure returns this same code, so it never reveals which tokens exist. Console only, not part of the /v1 surface.
Fix. Request a new sign-in email and use the newest link once. A link is single use and short lived.
forbidden#
HTTP 403. The key is not allowed on the tenant named in ?tenant, or a test-scope key tried to mint a live-scope key.
Fix. Use a key bound to that workspace, or an operator key whose allowlist includes it. Mint live keys from a live key or the console.
csrf_failed#
HTTP 403. A console-session mutation (POST, PATCH or DELETE) arrived without a valid CSRF token. Bearer-key calls never see this; a key is not a cookie.
Fix. Reload the console so it picks up a fresh console_csrf cookie and retry the action. If you are scripting the console API, send the cookie value in the CSRF header the console uses.
tenant_required#
HTTP 400. An operator key called a route without ?tenant. Operator keys serve several workspaces and must name one on every read and admin call.
Fix. Pass tenant (the tenant client option in the SDKs, --tenant or HOOKCHAT_TENANT in the CLI). Send routes never take a tenant; the conversation id scopes them.
invalid_request#
HTTP 400. The body is malformed or a required field is absent: no conversation_id, no text and no attachments, a scope that is not live or test, or a webhook URL that failed the SSRF policy (private, loopback, link-local or not HTTPS).
Fix. Read error.detail; it names the field or the URL rule. The SDKs refuse the common mistakes locally before any request is sent.
tenant_slug_taken#
HTTP 409. Self-serve workspace creation in the console used a slug that already exists. Console only.
Fix. Choose another slug. Slugs are global across HookChat.
member_not_found#
HTTP 404. Team management tried to remove a user who is not a member of this workspace. Console only.
Fix. Refresh the team list; the member may already have been removed.
owner_immutable#
HTTP 409. Team management tried to remove an owner membership, which would orphan the workspace. Console only.
Fix. Transfer ownership first, or leave the owner in place. Owners cannot be removed through the team UI.
invite_not_found#
HTTP 404. Team management tried to revoke a pending invite that does not exist for this workspace. Console only.
Fix. Refresh the invites list; the invite may have been accepted or already revoked.
oauth_state_invalid#
HTTP 400. The hosted Meta OAuth callback received a state that was missing, tampered, signed with the wrong key or older than 600 seconds. The token exchange is never attempted. Console only.
Fix. Start the account link again from the console and finish it within ten minutes in the same browser.
pending_link_not_found#
HTTP 404. The Meta asset-discovery session has expired (about ten minutes), was already consumed by a successful selection, or belongs to another workspace. Console only.
Fix. Start the account link again from the console.
conversation_not_found#
HTTP 404. No such conversation under the key's tenant. A cross-tenant id is a 404, never a 403, so ids do not leak across workspaces.
Fix. Take the id from a message.received event or the conversations list. Ids contain # characters; quote them in a shell and URL-encode them in a path (the SDKs and CLI do this for you).
message_not_found#
HTTP 404. No such message id under the key's tenant. A message written by a test-scope key is not found by a default read.
Fix. Use the platform message id (mid) from the event or the feed. Pass include_test to see rows written by test keys.
endpoint_not_found#
HTTP 404. No such webhook endpoint under the tenant, or it has been deleted. Deleting tombstones the endpoint, so a later read is a 404.
Fix. Check hookchat webhooks list; a deleted endpoint is not listed. Register a new one if it was removed.
key_not_found#
HTTP 404. The key id to revoke does not exist under this tenant. Revoking an already revoked key succeeds, so this is an unknown or cross-tenant id.
Fix. Use the id from keys list, not the prefix or the secret.
delivery_not_found#
HTTP 404. No such delivery under this endpoint. A delivery id from another endpoint or tenant is a 404, never a 403.
Fix. Take the delivery id from webhooks deliveries list for the same endpoint id.
replay_conflict#
HTTP 409. The replay was refused because the delivery already succeeded, or because an attempt is in flight right now.
Fix. Pass force to re-send a delivered event; the receiver then sees the same event id again, which is why it should dedupe on the id. For a mid-attempt delivery, wait a moment and retry. CLI exit 8.
window_closed#
HTTP 409. The 24 hour reply window is closed. On the reply route this also covers the human_agent_only state (24 hours to 7 days).
Fix. Read the conversation: if can_send_as_human_agent is true, send on the human-agent route with an actor_id; if it is false, wait for the participant to write again. Retrying the reply does not help. CLI exit 6.
human_agent_unavailable#
HTTP 409. The human-agent route was called on a conversation past the 7 day window. Nothing exists past 7 days on any platform.
Fix. Wait for the participant's next message, which reopens the 24 hour window. CLI exit 6.
missing_actor#
HTTP 409. A human-agent send omitted actor_id. The human who sent the message must be named; this is a policy refusal, not a malformed request.
Fix. Pass the id of the person who wrote the message (actor_id, --actor). Do not substitute a service name to automate an out-of-window send. CLI exit 6.
rate_limited#
HTTP 409, or 429 on sign-in. On a send: the account's per-second budget for this category (text or media) is exhausted, usually because of a retry loop. On POST /console/auth/request: too many sign-in emails for this address or IP inside the window, returned as a 429 with Retry-After.
Fix. Back off and retry after the budget refills; see Rate limits below. Fix any loop that resends on a 409. For sign-in, wait for Retry-After before requesting another link. CLI exit 7.
send_failed#
HTTP 502. Policy allowed the send and the delivery itself failed: Meta refused it or the network dropped. error.detail carries Meta's message. This is the one non-policy send failure.
Fix. Read detail. A transient reason can be retried; a policy reason from Meta (for example a blocked participant) cannot. Unlike a 409, a retry can help here. CLI exit 8.
Signature mismatch#
The verifier throws signature mismatch, signature timestamp outside tolerance, missing HookChat-Signature header or malformed signature header. In order of likelihood:
- The body was re-serialised. A framework parsed the JSON and you verified the re-encoded object. Read the raw bytes on the webhook route (
express.raw, Flaskrequest.get_data(),io.ReadAll(r.Body)) and verify those. This is the cause nine times out of ten. - The wrong secret. The endpoint's
whs_secret is shown once at creation or rotation. Compare the first characters withsigning_secret_prefixon the endpoint (hookchat webhooks get). After two rotations inside 24 hours the original secret no longer signs. - Clock skew. The timestamp must be within 300 seconds of your clock. The gateway stamps a fresh
ton every attempt, so a live delivery is never old; a rejection means the receiving host's clock is off, or you are verifying a captured delivery later (usehookchat verify --tolerancefor fixtures). - A proxy changed the body. Compression, a body-rewriting middleware or a JSON pretty-printer in front of the receiver alters the bytes. Verify at the first hop that sees the original body.
- Header case or joining. Header names are case-insensitive and the SDK verifiers read any case. If you extract the header yourself, join multiple values with a comma and keep every
v1=.
HookChat-Signature: t=1756900000,v1=3f9c1a0b2d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8Prove the receiver with a test event, and verify a captured delivery offline with hookchat verify --secret "$WEBHOOK_SECRET" --file delivery.json, which exits 9 on a mismatch.
The reply window#
A window_closed refusal is the gateway enforcing Meta's policy, not a bug to work around. The window is computed from the participant's last inbound message and exposed on every conversation as window.state, can_reply and can_send_as_human_agent; read those before you send. Between 24 hours and 7 days a named human can follow up on the human-agent route; past 7 days nothing can be sent until the participant writes again. There are no message tags and no generic send.
# Exit 6 and the code on stderr. Check can_send_as_human_agent on the conversation before offering the human-agent route.
hookchat messages reply --conversation "$CLOSED_CONVERSATION_ID" --text "Hello again"401 and key scopes#
A 401 unauthorized is always about the bearer key: missing, mistyped, revoked, or sent to a gateway other than the one that minted it. The key is sent as Authorization: Bearer hookchat_live_...; the prefix tells you its scope.
| Symptom | Likely cause | Fix |
|---|---|---|
| 401 on every call | The key is wrong, revoked, or aimed at the wrong base URL. | Check hookchat keys list from a working key and hookchat auth status for the active base URL. |
403 forbidden on keys create | A test-scope key tried to mint a live key. | Mint live keys from a live key or the console. |
403 forbidden with ?tenant | An operator key named a workspace it is not allowed on. | Use the right tenant slug, or a key bound to that workspace. |
400 tenant_required | An operator key omitted ?tenant. | Pass tenant in the client, or --tenant in the CLI. |
| Sends succeed but no message arrives | A hookchat_test_ key routes sends through the mock connection. | Use a hookchat_live_ key for real traffic. Test rows are visible with include_test. |
| Events never reach the endpoint | The events were produced under a test key; test-scoped events are not delivered unless the endpoint opts in. | Send a test.event to prove the endpoint, then use a live key. |
Rate limits#
Send budgets are per linked account, per second, and mirror Meta's published Send API limits: 100 text sends, 10 media sends. They exist so a bug such as a retry loop cannot become an enforcement event, not because normal traffic gets near them. Exhausting one returns 409 rate_limited; the SDKs never retry a send, so back off in your code and fix the loop.
Console sign-in has its own caps: 5 requests per email and 20 per IP in a 15 minute window, returned as 429 rate_limited with Retry-After. Reads that hit an HTTP 429 from the platform are retried by the SDKs with the Retry-After value, capped at 60 seconds.
Delivery failures and the DLQ#
A delivery that does not get a 2xx is retried on a fixed schedule (5s, 30s, 5m, 30m, 2h, 6h, 24h) for eight attempts in total, then dead-lettered with status dlq. Only 408, 429, 503 and other 5xx, transport errors and timeouts are retried; any other 4xx is terminal (failed) and a URL refused by the SSRF policy is blocked. Five consecutive dead letters with no success between them pause the endpoint automatically.
| You see | What it means | What to do |
|---|---|---|
last_response_status 401 | Your receiver rejected the signature. | See Signature mismatch. Once fixed, replay. |
last_response_status 404 or 405 | The URL or method is wrong on your side. | Update the endpoint URL (hookchat webhooks update --url), then replay. |
retrying with last_response_status null | The receiver timed out or the connection dropped. | Answer within a few seconds; queue the work and return 200 first. |
blocked | The URL is private, loopback, link-local, redirects, or is not HTTPS. | Point the endpoint at a public HTTPS URL that answers directly. |
Endpoint status is paused | Five deliveries in a row dead-lettered. | Fix the receiver, hookchat webhooks update --enabled, then deliveries replay-all --from <outage start>. |
The full procedure, including bulk replay, is on Inspect and replay deliveries.
CORS#
The API grants credentialed CORS to exactly one origin set: the console's own hosts. A browser page on any other origin that calls /v1 directly fails the preflight, and that is by design: an API key in a browser bundle is a leaked key. Call HookChat from your server and expose your own endpoint to the browser. The Meta ingest route and /health have no CORS at all; they are server to server.
Sandbox email#
Console sign-in is a magic link sent by email. The sending domain is verified with DKIM, a custom MAIL FROM and DMARC, but the SES account stays in the sandbox for now, so sign-in mail only reaches addresses that have been verified as SES identities. If you requested a link and nothing arrived, and the address is not on the verified list, that is why: ask an owner to have the address verified, or sign in from one that is. A 429 on the request means the per-email or per-IP sign-in cap was hit; wait for Retry-After. Check the spam folder before anything else.
CLI authentication#
The CLI resolves credentials in a fixed order, first match wins: the --api-key, --base-url and --tenant flags; then the HOOKCHAT_API_KEY, HOOKCHAT_BASE_URL and HOOKCHAT_TENANT environment variables; then the profile named by --profile or HOOKCHAT_PROFILE, or the default profile, in the config file that hookchat auth login writes ($HOOKCHAT_CONFIG_DIR/config.json or the user config directory, mode 0600). An environment variable therefore silently overrides a profile; auth status shows which source won.
hookchat auth status| Exit | Meaning | Fix |
|---|---|---|
| 3 | unauthorized, forbidden, tenant_required, or no API key configured at all. | hookchat auth login, or set HOOKCHAT_API_KEY, or pass --api-key. Operator keys need --tenant. |
| 4 | A *_not_found code. | Check the id; quote conversation ids, which contain #. |
| 5 | invalid_request. | Read the detail line. |
| 6 | window_closed, human_agent_unavailable or missing_actor. | See The reply window. |
| 7 | rate_limited or an HTTP 429. | Back off; see Rate limits. |
| 8 | send_failed or replay_conflict. | Read detail; add --force to replay a delivered event. |
| 9 | Signature verification failed in hookchat verify. | See Signature mismatch. |
| 10 | Network error, timeout or a 5xx without an envelope. | hookchat ping --base-url ... checks reachability; --timeout raises the limit. |
Errors are printed to stderr as error: <code>: <message> with detail, request_id and a docs: link to the matching section on this page. Branch on the exit code or the code token, never on the message text. The complete flag list is in the CLI reference.