Send as a human agent
Past the 24 hour window there is exactly one way to reach a person: a message typed by a named human, sent on the human-agent route, within 7 days of their last message. HookChat holds the message until that person passes a short check, then sends it. There is no generic send and there are no message tags.
When to use it#
The route is for a real person following up on a real thread: a delayed answer, a question that needed research, a booking confirmation that came through after hours. It is not a broadcast channel and it cannot be automated, which is a policy HookChat enforces rather than a setting you can change.
window.stateisopen_24h: either route works. Prefer the reply route for automated answers. Inside the 24 hours a human-agent message goes out as an ordinary message, still recorded with itsactor_id, so it needs no Meta approval.window.stateishuman_agent_only: only this route works.can_send_as_human_agentis true andcan_replyis false.window.stateisclosed: nothing can be sent. Past 7 days the route refuses withhuman_agent_unavailable.
How a human-agent send works#
- You post the message with
actor_id. HookChat checks it as it would send it (the conversation, the window, the send budget), sends nothing, and answers 202 with ahold: itsid,statusawaiting_verification,expires_atand averify_url. - You hand
verify_urlto the person who typed the message, in your own app. It carries a one-time secret after#t=: treat it like a password and never log it. - They open it. The page shows the message and runs Cap, a short proof of work in the browser. When it passes, HookChat sends the message in that same moment, working out the window again first.
- The usual
message.sentevent reaches your webhook, andGET /v1/messages/holds/{hold_id}readssentwith theplatform_message_id. If the window closed while the hold waited, the hold readsfailedand says why.
actor_id.A hold waits 15 minutes, and never past the end of the 7 day window. If nobody passes the check by then it reads expired and nothing is sent; send the message again for a new link. A hold sends once. In the console, the confirmation step runs the same check inline, so a person sending from the console never sees a link.
Send as a named human#
The request is the reply request plus actor_id, the identity of the person who wrote the message. Use whatever identifies them in your own system: a user id, an agent handle, a console session. It is recorded in the audit trail and on the message as actor_id. A test key holds the same way and sends through the mock connection, so you can build your own flow before going live.
import { HookChat } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
// actor_id is required by the type: omitting it does not compile.
const hold = await client.messages.sendAsHumanAgent({
conversation_id: process.env.CONVERSATION_ID!,
text: 'Sorry for the delay, following up personally.',
actor_id: 'agent-7',
})
// Hand hold.verify_url to agent-7 in your own app. Never log it: it holds a secret.
console.log('held', hold.id, hold.status)The answer is 202 { ok: true, data: { hold } }, not the reply route's platform_message_id: nothing has been sent yet. Attachments and reply_to work as on the reply route: a send carries text or attachments, not both, and up to 10 images go as one message, one hold per message. See Send media. The CLI prints verify_url, opens it in your browser when it runs in a terminal (--no-open to skip), and waits for the hold; --no-wait prints the hold and returns.
Wait for the hold#
Each SDK has a helper that reads the hold until it ends: every second for five seconds, then every two. It returns the hold once it is sent. A failed hold raises the API error it carries (such as human_agent_unavailable when the window closed while it waited), an expired one raises hold_expired (409), and past the timeout (15 minutes by default) it raises a hold timeout error while the hold goes on waiting. Or skip the wait and act on the message.sent event your webhook receives.
import { HookChat, HoldTimeoutError } from '@hookchat/node'
const client = new HookChat({ apiKey: process.env.HOOKCHAT_API_KEY!, baseUrl: process.env.HOOKCHAT_BASE_URL! })
const hold = await client.messages.sendAsHumanAgent({
conversation_id: process.env.CONVERSATION_ID!,
text: 'Sorry for the delay, following up personally.',
actor_id: 'agent-7',
})
try {
// The default wait is 15 minutes; this sample waits 3 seconds.
const sent = await client.messages.waitForHold(hold.id, { timeout_ms: 3_000 })
console.log('sent', sent.platform_message_id)
} catch (error) {
if (!(error instanceof HoldTimeoutError)) throw error
console.log('still waiting for the check on', hold.id)
}Build your own check page#
HookChat's page at verify_url is enough for most teams. To run the check inside your own app, read the hold id from the hold query parameter and the secret from the part after #t=.
Then call the public /verify routes with the secret in the body. POST /verify/holds/{hold_id} gives what to show. POST /verify/holds/{hold_id}/challenge gives a Cap challenge. POST /verify/holds/{hold_id}/complete with secret, cap_token and solutions passes the check and sends.
A wrong solution is a 422 verification_failed and the hold keeps waiting; a second complete is a 409 hold_expired. The routes take no API key and are limited per address and per hold. See the API reference.
Past seven days#
When the conversation is closed the route refuses with human_agent_unavailable (409). Nothing exists past 7 days on any platform; the right move is to wait for the participant to write again, which reopens the 24 hour window.
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.sendAsHumanAgent({
conversation_id: process.env.CLOSED_CONVERSATION_ID!,
text: 'Checking in',
actor_id: 'agent-7',
})
} catch (error) {
if (error instanceof HookChatError && error.code === 'human_agent_unavailable') {
console.log('refused:', error.code)
} else {
throw error
}
}Why actor_id is required#
A human-agent message is human-sent by definition, so the gateway refuses one that names nobody with missing_actor (409). That is a policy refusal, not a validation error: the body was well formed, the request was simply not allowed. The SDKs make the mistake hard to write: the TypeScript type requires the field, the Python method takes it as a required keyword, and the Go client refuses an empty ActorID locally with the same code.
actor_id to get an automated follow-up through. The route exists because a real person must stand behind an out-of-window message, and the audit trail records who that was.Turn on the Human Agent feature first#
Meta puts this route behind an app feature it calls Human Agent. The account you connected must have it approved before any out-of-window message goes through. Until then Meta refuses the send with (#100) Cannot tag messages with 'HUMAN_AGENT' without prior approval, and the hold ends failed with send_failed and Meta's wording in error.detail.
To turn it on, request the Human Agent feature for your app in the Meta app dashboard, under app review and advanced access, and finish Meta's review. Each connected account is a separate app grant, so approve it for every app that backs a location. In-window replies never need it; only the human-agent route does, and a retry will not help until the feature is approved.
Errors the human-agent 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. |
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. |
missing_actor | 409 | No actor_id. The human who sent it must be named. |
human_agent_unavailable | 409 | The conversation is past the 7 day window, or has no inbound message yet. |
rate_limited | 409 | The account's send budget for this category is spent. |
hold_not_found | 404 | GET /v1/messages/holds/{hold_id} or the check page: no such hold, another workspace's, gone after 30 minutes, or a wrong secret. |
hold_expired | 409 | The check page: the hold was sent or failed already, or its 15 minutes ran out. |
verification_failed | 422 | The check page: the Cap solution did not pass. The hold keeps waiting. |
A send that Meta refuses after the check (send_failed, a missing Human Agent approval among them) does not come back on the 202: the hold reads failed with that code in error, and the message.failed event follows.