Sending messages
HookChat has exactly two send routes: an in-window reply and an out-of-window human-agent send. There is no generic send, and there are no message tags. Meta Message Tags ended on 10 February 2026; the human-agent route is the only way to reach someone outside the 24 hour window.
The messaging window model#
Meta only lets a business message a person inside time windows measured from that person last inbound message. HookChat computes the window for every conversation server-side and surfaces it: you never recompute the Meta rules. There are three states:
open_24h: within 24 hours of the last inbound message. Any reply is allowed.human_agent_only: from 24 hours to 7 days. Only a human-typed message on the human-agent route is allowed.closed: past 7 days, or no inbound message yet. Nothing can be sent past 7 days, on any platform.
Only the person's own messages open the window. A message your team sends from the Instagram or Messenger app shows in the thread as direction: "echo" and reaches your endpoint as message.sent, but it does not open or extend the window. A thread your team starts from the app is in the inbox with last_inbound_at null, the window closed and can_reply false until the person writes back.
Every conversation carries the computed window and two boolean flags, so your UI never has to guess:
{
"window": { "state": "open_24h", "expires_at": "2026-08-29T03:14:22Z" },
"can_reply": true,
"can_send_as_human_agent": true
}can_reply: true only while the window isopen_24h.can_send_as_human_agent: true while the window is not closed (open_24horhuman_agent_only).
window.expires_at is a snake_case ISO-8601 timestamp, like every other timestamp in the API. It is absent when state is closed: a closed window has no expiry.Targeting: conversation_id only#
Both send routes target a single conversation by its conversation_id. That id, and the key you authenticate with, is the whole addressing scheme. There is deliberately no `?tenant` parameter on the send routes: the conversation id already scopes the send to one conversation in one workspace.
Reply in-window: POST /v1/messages/reply#
Sends a reply on a conversation whose 24 hour window is open. A send carries text or attachments, never both: a Meta message is text or media, so words and a picture take two calls. Out of the 24 hour window this route refuses with window_closed (409); use the human-agent route instead.
POST /v1/messages/reply HTTP/1.1
Host: hookchat.dev
Authorization: Bearer hookchat_live_...
Content-Type: application/json
{
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"text": "Happy to help, can you send a reference image?"
}On success:
{ "ok": true, "data": { "platform_message_id": "mid.def456" } }Human-agent send: POST /v1/messages/human-agent#
The only route out of the 24 hour window (from 24 hours to 7 days), and it is human-sent by definition. actor_id is required: the human who sent the message 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).
POST /v1/messages/human-agent HTTP/1.1
Host: hookchat.dev
Authorization: Bearer hookchat_live_...
Content-Type: application/json
{
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"text": "Sorry for the delay, following up personally.",
"actor_id": "console:01H"
}Nothing is sent by this call. HookChat checks the send, 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 in the browser); then HookChat sends it, working out the window again, and message.sent follows. The check raises the cost of sending by script; it does not prove a person. A hold waits 15 minutes, never past the 7 day window. Read it with GET /v1/messages/holds/{hold_id}. See Send as a human agent.
{
"ok": true,
"data": {
"hold": {
"id": "hold_01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
"status": "awaiting_verification",
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"created_at": "2026-08-29T03:14:22.000Z",
"expires_at": "2026-08-29T03:29:22.000Z",
"verify_url": "https://app.hookchat.dev/verify/?hold=hold_01J9Z8Y7X6W5V4T3S2R1Q0P9N8#t=..."
}
}
}Attachments and reply_to#
Both routes accept an attachments array in place of text. Each item is { type, url }, which Meta downloads from the URL, or { type, attachment_id } for a file sent from your computer: ask POST /v1/attachments/uploads for an upload url, post the file to it, finish with POST /v1/attachments, then read GET /v1/attachments/uploads/{upload_id} until it is ready and carries the id. Each SDK and the CLI do all of it in one call; see Send a file from your computer.
Up to 10 images go together as one message; a video, audio or file goes alone. A send with both text and attachments, more than 10 images, or an image mixed with other media is a 400 invalid_request. See Send media for the types and sizes Meta takes.
An audio or video attachment spends the media rate budget, an image or a file the text one. The optional reply_to (a platform message id) is a documented no-op where the platform does not support replies. Neither changes the window, rate, or policy decision.
Errors#
Failures use the standard envelope: { ok: false, error: { code, message, detail? } }. Branch on error.code, a fixed code you can match on, never on error.message. A 409 means the request was well-formed and the state of the thread refused it (retrying will not help). A 502 send_failed is the one exception: policy allowed the send and the delivery itself died, so a retry usually helps; detail says when it cannot. On the human-agent route Meta's refusal comes later, on the hold, as failed with the same code.
| code | HTTP | Route | Meaning |
|---|---|---|---|
invalid_request | 400 | both | Malformed body or an absent required field: for example no conversation_id, no text and no attachments, both text and attachments, more than 10 images, or an image mixed with other media. |
unauthorized | 401 | both | Missing or invalid bearer API key. |
conversation_not_found | 404 | both | No such conversation. An id from another workspace is a 404, never a 403: ids do not leak across workspaces. |
window_closed | 409 | reply | The 24 hour reply window is closed. This also covers the human-agent-only state on the reply route; the conversation can_send_as_human_agent flag tells you whether to offer that path. |
human_agent_unavailable | 409 | human-agent | The conversation is past the 7 day human-agent window. Nothing exists past 7 days (Meta policy). |
missing_actor | 409 | human-agent | A human-agent send omitted actor_id. The human who sent it must be named; this is a policy refusal, not a malformed request. |
rate_limited | 409 | both | The send budget of the account for this category is exhausted. |
send_failed | 502 | reply | Policy allowed the send and the delivery itself failed (Meta refused, or the network). The one non-policy failure: a retry usually helps, where retrying a 409 cannot, and detail says when it cannot. detail carries the Meta message. |
These are the only codes the send routes return. The full API-wide vocabulary (including forbidden, tenant_required, the *_not_found family, and replay_conflict) is a closed set; see the API reference for every code by route and Webhooks and events for the delivery-side contract.