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)

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 text or attachments, not both. A Meta message is text or media, so words and a picture take two calls. A send with both is a 400 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.

typeInstagramMessenger
imagepng or jpeg, up to 8 MBUp to 8 MB
videomp4, ogg, avi, mov or webm, up to 25 MBUp to 25 MB; Meta stops the fetch after 75 seconds
audioaac, m4a, wav or mp4, up to 25 MBUp to 25 MB
filePDF only, up to 25 MBUp to 25 MB
Three images as one message
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" }
  ]
}
A PDF in its own call
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" }]
}
The words in a third call, in a thread
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.

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, 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.
unauthorized401Missing or invalid bearer key.
conversation_not_found404No such conversation. An id from another workspace 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 usually helps; the detail says when it cannot.

Every code has a cause and a fix on the troubleshooting page.

Next#