Reply within the window
Meta lets a business message a person for 24 hours after that person's last message. HookChat computes that window for every conversation and exposes it as data, so your code reads a flag instead of reimplementing Meta's rules.
The window model#
Every conversation carries a window object and two booleans, computed server-side from the participant's last inbound message. Consumers never recompute them.
| window.state | Meaning | can_reply | can_send_as_human_agent |
|---|---|---|---|
open_24h | Within 24 hours of the last inbound message. | true | true |
human_agent_only | From 24 hours to 7 days. Only a named human may send, on the human-agent route. | false | true |
closed | Past 7 days, or no inbound message yet. Nothing can be sent. | false | false |
window.expires_at is an ISO-8601 timestamp for when the current state ends. It is absent when the state is closed.
Check the window first#
Fetching the conversation gives you the flags and the recent thread in one call. Read can_reply before you build UI or queue a send; it saves a refused request.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const { conversation, messages } = await client.conversations.get(process.env.CONVERSATION_ID!)
console.log('window', conversation.window.state, 'expires', conversation.window.expires_at)
console.log('can_reply', conversation.can_reply, 'can_send_as_human_agent', conversation.can_send_as_human_agent)
console.log(messages.length, 'recent messages')Send the reply#
The reply route takes the conversation id and the text. On success it returns the platform's own id for the delivered message. There is no tenant parameter: the conversation id already scopes the send.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const { platform_message_id } = await client.messages.reply({
conversation_id: process.env.CONVERSATION_ID!,
text: 'Happy to help. Can you send a reference image?',
})
console.log('sent', platform_message_id)Send media#
Both send routes take an attachments array in place of text. An attachment is { type, url } where type is image, video, audio or file. Meta downloads the URL, so it must be public https; HookChat does not store the file.
A file on your computer goes as { type, attachment_id } instead: upload it first, and HookChat hands it to Meta and keeps it only until Meta has it. Each SDK does that in one call. See Send a file from your computer.
- A send carries
textorattachments, not both. A Meta message is text or media, so words and a picture take two calls. A send with both is a400 invalid_request. - Up to 10 images go together as one message, with one
platform_message_id. - A video, audio or file goes alone, one per send. Any other mix, or more than 10 images, is a
400 invalid_request. - Audio and video spend the media send budget; images and files spend the text one.
Meta sets the types and sizes it takes. A file too big or of the wrong type passes HookChat and fails at Meta as a 502 send_failed; error.detail carries Meta's reason, and a retry will not help.
Meta fetches the URL itself, as facebookexternalhit, and it obeys robots.txt. A site whose robots.txt disallows crawlers answers Meta with a 403, and the send fails with Upload failed and a hint in error.detail. The URL must also be public https and must not redirect. To send a file that is not online, upload it from your computer instead.
| type | Messenger | |
|---|---|---|
image | png or jpeg, up to 8 MB | Up to 8 MB |
video | mp4, ogg, avi, mov or webm, up to 25 MB | Up to 25 MB; Meta stops the fetch after 75 seconds |
audio | aac, m4a, wav or mp4, up to 25 MB | Up to 25 MB |
file | PDF only, up to 25 MB | Up to 25 MB |
POST /v1/messages/reply HTTP/1.1
Authorization: Bearer hookchat_live_...
Content-Type: application/json
{
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"attachments": [
{ "type": "image", "url": "https://cdn.example.com/rooms/1.jpg" },
{ "type": "image", "url": "https://cdn.example.com/rooms/2.jpg" },
{ "type": "image", "url": "https://cdn.example.com/rooms/3.jpg" }
]
}POST /v1/messages/reply HTTP/1.1
Authorization: Bearer hookchat_live_...
Content-Type: application/json
{
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"attachments": [{ "type": "file", "url": "https://cdn.example.com/quotes/1042.pdf" }]
}POST /v1/messages/reply HTTP/1.1
Authorization: Bearer hookchat_live_...
Content-Type: application/json
{
"conversation_id": "CONV#acme#instagram#17841405309211844#6021573449812077",
"text": "Here is the quote you asked for.",
"reply_to": "mid.abc123"
}reply_to threads a send under a platform message id and is a documented no-op where the platform does not support it. Neither it nor attachments changes the window decision.
{ "ok": true, "data": { "platform_message_id": "mid.def456" } }When the window has closed#
Outside the 24 hours the reply route refuses with window_closed (409). That code also covers the human_agent_only state on this route: the request was well formed and the conversation's state refused it, so retrying cannot help. Branch on the code, then check can_send_as_human_agent to decide whether to offer the human-agent route.
import { HookChat, HookChatError } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
try {
await client.messages.reply({ conversation_id: process.env.CLOSED_CONVERSATION_ID!, text: 'Hello again' })
} catch (error) {
if (error instanceof HookChatError && error.code === 'window_closed') {
// Branch on the stable code, never on the message text.
console.log('refused:', error.code, 'status', error.status)
} else {
throw error
}
}Errors the reply route returns#
| code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Malformed body, no conversation_id, no text and no attachments, both text and attachments, more than 10 images or an image mixed with other media, or a text past the limit: 1000 characters on Instagram, 2000 on Messenger. An emoji counts as one. |
unauthorized | 401 | Missing or invalid bearer key. |
conversation_not_found | 404 | No such conversation. An id from another workspace is a 404, never a 403. |
window_closed | 409 | The 24 hour window is closed, including the human-agent-only state. |
rate_limited | 409 | The account's send budget for this category (text or media) is spent. |
send_failed | 502 | Policy allowed the send and Meta or the network refused it. detail carries Meta's message. A retry usually helps; the detail says when it cannot. |
Every code has a cause and a fix on the troubleshooting page.