Troubleshooting
Every failure HookChat returns carries a stable machine code, and the CLI and the SDKs link straight to its entry here. This page lists each code with its cause and its fix. After that come the failures with no error envelope at all: 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 one of the fixed codes below, error.message is for people and may change, and error.detail appears only when there is more to say that is safe to share. Message content never appears in an error.
{
"ok": false,
"error": {
"code": "window_closed",
"message": "the 24 hour 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. The SDKs keep it on the error as requestId (TypeScript), request_id (Python) and RequestID (Go), the CLI prints it as request_id, and the console shows it next to a failed load. 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 workspace named in ?tenant, or a test-scope key tried to create a live-scope key.
Fix. Use a key bound to that workspace, or an operator key whose allowlist includes it. Create live keys from a live key or the console.
csrf_failed#
HTTP 403. A console-session write (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 workspace; 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). On a send, it is also both text and attachments, more than 10 images, or an image mixed with other media.
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.
already_member#
HTTP 409. A team invite named an address that is already a member of this workspace. Console only.
Fix. Nothing to do; they are in. To change their role, remove and re-invite them.
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 connect 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.
route_not_found#
HTTP 404. No route at that method and path. A typo in the path, a missing /v1 prefix, or a POST where the route takes a GET.
Fix. Check the method and the path against the API reference. The reply still carries the envelope and the x-correlation-id, so a wrong path is never mistaken for an outage.
conversation_not_found#
HTTP 404. No such conversation in the key's workspace: it expired, it was erased, or it never existed. An id from another workspace is a 404, never a 403, so ids do not leak across workspaces. Erasing an id from your own workspace that holds nothing is not a 404: it answers 200 with zero counts.
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 in the key's workspace. 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 in the workspace, 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 in this workspace. Revoking an already revoked key succeeds, so this is an unknown or id from another workspace.
Fix. Use the id from keys list, not the prefix or the secret.
account_not_found#
HTTP 404. The account key to disconnect is not connected to this workspace. It was never linked, it is already disconnected, or it belongs to another workspace.
Fix. Use the account_key from accounts list, such as instagram#17841400000000001, and URL-encode the # as %23 in a raw request.
delivery_not_found#
HTTP 404. No such delivery under this endpoint. A delivery id from another endpoint or workspace is a 404, never a 403.
Fix. Take the delivery id from webhooks deliveries list for the same endpoint id.
upload_not_found#
HTTP 404. Finishing an upload (POST /v1/attachments) found no upload to finish: the id is unknown, it began more than a day ago, it was already finished, or it belongs to another workspace. The same code, with its own message, says no file has been posted to the upload url yet.
Fix. If no file was posted yet, post it with every field from the upload, then finish again; nothing was used up. Otherwise ask for a new upload with POST /v1/attachments/uploads. An upload finishes once, even when Meta refused the file.
hold_not_found#
HTTP 404. No such held human-agent message: the hold id is unknown, it belongs to another workspace, it began more than 30 minutes ago, or (on the check page's /verify routes) the secret from verify_url is wrong. The answer is the same for each.
Fix. Use the hold id from the POST /v1/messages/human-agent answer, with the key of the same workspace. On the check page, open the whole verify_url, including the part after #t=. CLI exit 4.
hold_expired#
HTTP 409. The held message can no longer be checked: it was sent or failed already, or nobody passed the check within its 15 minutes (never past the end of the 7 day window). A hold sends once.
Fix. Read the hold with GET /v1/messages/holds/{hold_id} to see how it ended. To send the message, send it again for a new verify_url. CLI exit 8.
verification_failed#
HTTP 422. The check on a held human-agent message did not pass: the Cap solution is wrong, belongs to another hold, or its challenge expired. The hold keeps waiting.
Fix. Run the check again on the same page; it fetches a new challenge. If you built your own page, get a fresh challenge from /verify/holds/{hold_id}/challenge and send its token back as cap_token. CLI exit 5.
attachment_too_large#
HTTP 413. The file is over Meta's limit for its kind: 8 MB for an image, 25 MB for a video, audio or file. It is checked on the size you declare when the upload begins, and again on the real file when it finishes.
Fix. Make the file smaller, or send a large video by a public url Meta can fetch instead. Declare the real size in bytes.
unsupported_media_type#
HTTP 415. The account does not take this content type as this kind of attachment, or the file's first bytes are not the type it claimed. Instagram takes a PDF as its only file type, a gif cannot go to an Instagram Login account, and no account takes a program.
Fix. Send a type the account takes, and declare the file's real content type, such as image/jpeg for a JPEG, with no parameters. A renamed file keeps its old type inside.
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.
replay_expired#
HTTP 409. The replay was refused because the event body is no longer kept. A delivery record is kept 90 days, but the event body it carried only 7 (body_expires_at on the delivery), so there is nothing left to send.
Fix. Nothing to retry. Replay within 7 days of the event; for an older one, rebuild what you need from your own copy of the event. A bulk replay counts such a delivery in skipped. CLI exit 8.
window_closed#
HTTP 409. The 24 hour reply window is closed, or the conversation has no inbound message yet, so no window has opened. On the reply route this also covers the human_agent_only state (24 hours to 7 days). The message says which.
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, or on one with no inbound message yet. Nothing exists past 7 days on any platform, and nothing opens before the participant writes.
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 an API key or sign-in. On a send: the account's per-second budget for text or media is used up, usually by a retry loop. On any /v1 route: the API key has used its 600 requests this minute, a 429 with Retry-After. On POST /console/auth/request: too many sign-in emails for this address or IP, also a 429 with Retry-After.
Fix. Wait for Retry-After, then try again. Fix any loop that resends on a 409; see Rate limits below. 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. A failed upload carries it as error.code when Meta refused the file.
Fix. Read detail. A transient reason can be retried; a policy reason from Meta (a blocked participant, say) cannot. A refused upload: check the file against Meta's limits. Upload failed on a URL: see Send media. CLI exit 8.
internal_error#
HTTP 500. Something broke inside HookChat while handling the call. It is rare, and it is our bug, not yours. The reply still carries the envelope and the request id. A failed upload carries it as error.code when HookChat could not hand the file to Meta.
Fix. Retry once; for a failed upload, start the upload again. If it happens again, write to support with the request id and the time; the SDKs and the CLI print both.
A reply with no error envelope#
Sometimes a reply does not come from a HookChat handler at all. The edge can answer 502 or 503 while a deploy settles, a request can time out with a 504, or a proxy between you and us can return its own HTML page. The SDKs surface these as an error with the HTTP status and no code, and the CLI prints http_<status> and exits 10.
What to do: treat it like a network failure. Retry with a short backoff (the SDKs already do for reads), check GET /v1/ping, and if it keeps happening send us the time and the request id from the x-correlation-id header.
Errors the SDK raises itself#
Some failures never reach a HookChat handler, so the SDK names them itself: the same error class as an API refusal, with a code of its own. There is no request id unless a reply arrived. The CLI prints timeout: or network: with a hint and exits 10.
timeout#
The attempt ran past its limit (30 seconds by default: timeoutMs, timeout or WithTimeout). Reads are retried first. Raise the limit, or check that the base URL answers GET /v1/ping.
network_error#
The connection failed before any reply: refused, DNS, TLS, a reset. Nothing reached the gateway. Check the base URL and your network; hookchat ping shows what the CLI sees.
connection_error#
The Python name for the same failure as network_error, raised as APIConnectionError; APITimeoutError is its timeout subclass.
aborted#
Your own AbortSignal or context cancelled the call. Nothing to fix on our side.
server_error#
A 5xx that carried no HookChat envelope, after the retry budget was spent. See A reply with no error envelope.
invalid_response#
The body was not the { ok, data | error } envelope, usually a proxy or a captive portal answering in place of the gateway. Check the base URL.
http_error#
The Python code for a reply with no HookChat envelope and a status below 500 (a 5xx is server_error, a 429 is rate_limited). See A reply with no error envelope.
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 issued it. The key is sent as Authorization: Bearer hookchat_live_...; the prefix tells you its scope. Manage API keys covers scopes, rotation and revoking.
| 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 create a live key. | Create 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 stay inside HookChat and never reach an endpoint. | 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.
Three more limits sit above the send budgets. Each answers 429 rate_limited, and each tells you when to try again.
| Limit | What you get | What to do |
|---|---|---|
API key: 600 requests per UTC minute, across every /v1 route and workspace | 429 rate_limited with Retry-After: the seconds until the next minute. The audit log records it. Console sessions and public ping do not spend this budget. | Wait for Retry-After. Reads are retried by the SDKs on their own, capped at 60 seconds. |
| Console sign-in: 5 requests per email and 20 per IP in 15 minutes | 429 rate_limited with Retry-After. | Wait for Retry-After before you ask for another link. |
| A burst past the front door | A plain 429 with no envelope: no ok field, no x-correlation-id. | Treat it as rate_limited and back off. The SDKs do, and read the request id from the apigw-requestid header instead. |
Delivery failures and giving up#
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 given up with status dlq. Only 408, 429, 503 and other 5xx, transport errors and timeouts are retried; any other 4xx is final (failed) and a URL refused by the SSRF policy is blocked. Five given-up deliveries in a row 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 given up. | 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.
An account says needs relink#
Once a day a job refreshes the token behind every linked account. Meta refuses when the person changes their password, removes the app or loses the role that granted access. When that happens, the account is marked with the refusal, the accounts screen shows a red needs relink badge with Meta's line under it, and one account.disconnected event goes to your endpoints. Messages for that account stop arriving until it is linked again.
| What you see | What to do |
|---|---|
| A red needs relink badge on the accounts screen | Click Reconnect on that row. Sign in as someone with access to the account and finish the consent. The new token replaces the old one and the badge clears as soon as the link finishes. |
| An Instagram account that needs relink | Reconnect sends you through Instagram Login, the only way to link Instagram. Sign in with the Instagram account itself. Connect Facebook / Messenger links Pages for Messenger only. |
An account.disconnected event at your endpoint | The same signal, for your own alerting. The event names the account and the workspace; it never carries the token or the reason. |
A conversation shows an id, not a name#
Meta sends each message with the sender's id only. HookChat looks up their name, username and picture just after, so a conversation read straight after its first message has no name yet. Read participant_profile on the conversation to see why.
| participant_profile | What to do |
|---|---|
pending | Wait for the participant.updated event, or read the conversation again in a few seconds. |
unavailable | Meta shared nothing for this person, which happens when their privacy settings hide it. The lookup is tried again on their next message. |
complete, but your endpoint never got participant.updated | Check the endpoint's event list. One set to a fixed list must include participant.updated. The delivery log shows whether it was sent. |
A message sent from the Instagram or Messenger app did not arrive#
A message your team sends from the Instagram or Messenger app reaches your endpoint as message.sent with direction: "echo". It waits a few seconds before it is sent, so an echo of one of your API sends is never delivered twice. There is nothing to set up on your side.
| What you see | What to do |
|---|---|
| No event yet, a moment after the send | Wait a few seconds. An echo is held briefly before it is delivered. |
message.received arrives, message.sent never does | Check the endpoint's event list. One set to a fixed list must include message.sent. The delivery log shows whether it was sent. |
message.sent arrives, but your code ignores it | Your handler may only expect direction: "outbound". Handle echo too, or branch on direction to tell the two apart. |
| You want to check HookChat got it | Open the thread in the console, or read it with GET /v1/conversations/{id} or hookchat messages list --direction echo. The message is there with direction: "echo". |
Sign-in email#
Console sign-in is a magic link sent by email from no-reply@hookchat.dev. The sending domain is signed with DKIM and has a custom MAIL FROM and DMARC, and the link reaches any address.
If you asked for a link and nothing came, check the spam folder first, then check the address for a typo. The reply to a sign-in request is the same whether or not the address has an account, so a typo looks like success. A 429 on the request means the per-email or per-IP sign-in cap was hit; wait for Retry-After. A link works once and then expires, so ask for a new one rather than reuse an old mail.
CLI authentication#
The CLI resolves credentials in a fixed order, and the first match wins. First the --api-key, --base-url and --tenant flags. Then the HOOKCHAT_API_KEY, HOOKCHAT_BASE_URL and HOOKCHAT_TENANT environment variables. Last, the profile named by --profile or HOOKCHAT_PROFILE, or the default profile, in the config file 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 |
|---|---|---|
| 1 | Something went wrong inside the CLI itself, or you answered no when keys revoke, webhooks delete or webhooks rotate asked first. | A no changed nothing; --yes skips the question. For anything else, rerun with --output json to see the whole error, and report it with the command you ran. |
| 2 | Usage: an unknown command, flag or argument. Usage is printed. | Run hookchat <command> --help. |
| 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, hold_not_found included. | Check the id; quote conversation ids, which contain #. |
| 5 | invalid_request, attachment_too_large, unsupported_media_type or verification_failed. | Read the message and the detail line. After verification_failed, run the check on the hold again. |
| 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, replay_conflict, replay_expired or hold_expired. | Read detail; add --force to replay a delivered event. A replay_expired delivery cannot be replayed. After hold_expired, send the message again for a new check link. |
| 9 | Signature verification failed in hookchat verify. | See Signature mismatch. |
| 10 | Network error, timeout (an upload or a hold that took longer than --wait-timeout too), or a 5xx from the gateway (internal_error, or no envelope). | hookchat ping --base-url ... checks reachability; --timeout raises the limit. On internal_error, retry once, then quote the request id. |
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. With --output json a failure is one JSON object on stderr instead: {"error": {"code", "message", "detail", "request_id", "docs", "exit"}}, so a script can parse it.