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.state is open_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 its actor_id, so it needs no Meta approval.
  • window.state is human_agent_only: only this route works. can_send_as_human_agent is true and can_reply is false.
  • window.state is closed: nothing can be sent. Past 7 days the route refuses with human_agent_unavailable.

How a human-agent send works#

  1. 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 a hold: its id, status awaiting_verification, expires_at and a verify_url.
  2. You hand verify_url to 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.
  3. 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.
  4. The usual message.sent event reaches your webhook, and GET /v1/messages/holds/{hold_id} reads sent with the platform_message_id. If the window closed while the hold waited, the hold reads failed and says why.
The check raises the cost of sending by script: every message costs a browser a piece of work, on a page a person opens. It does not prove a person is there, and it does not change who is responsible. The message must still be typed by the person named in 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.

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

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

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

Do not put a service name or a bot id in 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#

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.
unauthorized401Missing or invalid bearer key.
conversation_not_found404No such conversation. An id from another workspace is a 404, never a 403.
missing_actor409No actor_id. The human who sent it must be named.
human_agent_unavailable409The conversation is past the 7 day window, or has no inbound message yet.
rate_limited409The account's send budget for this category is spent.
hold_not_found404GET /v1/messages/holds/{hold_id} or the check page: no such hold, another workspace's, gone after 30 minutes, or a wrong secret.
hold_expired409The check page: the hold was sent or failed already, or its 15 minutes ran out.
verification_failed422The 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.

Next#