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)Reply with media or in a thread#
Both send routes accept an optional attachments array and an optional reply_to. An attachment is { type, url } where type is image, video, audio or file and Meta fetches the URL, so it must be publicly reachable. text becomes optional once there is at least one attachment. reply_to threads the send under a platform message id and is a documented no-op where the platform does not support it. Neither changes the window decision; attachments spend the media send budget.
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.",
"attachments": [{ "type": "file", "url": "https://cdn.example.com/quotes/1042.pdf" }],
"reply_to": "mid.abc123"
}{ "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, or no text and no attachments. |
unauthorized | 401 | Missing or invalid bearer key. |
conversation_not_found | 404 | No such conversation. A cross-tenant id 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 can help. |
Every code has a cause and a fix on the troubleshooting page.