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.stateMeaningcan_replycan_send_as_human_agent
open_24hWithin 24 hours of the last inbound message.truetrue
human_agent_onlyFrom 24 hours to 7 days. Only a named human may send, on the human-agent route.falsetrue
closedPast 7 days, or no inbound message yet. Nothing can be sent.falsefalse

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.

TypeScript
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.

TypeScript
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.

A media reply
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"
}
Success envelope
{ "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.

TypeScript
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#

codeHTTPMeaning
invalid_request400Malformed body, no conversation_id, or no text and no attachments.
unauthorized401Missing or invalid bearer key.
conversation_not_found404No such conversation. A cross-tenant id is a 404, never a 403.
window_closed409The 24 hour window is closed, including the human-agent-only state.
rate_limited409The account's send budget for this category (text or media) is spent.
send_failed502Policy 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.

Next#