Skip to main content
HQ lets an agent receive and reply to email. You mint an email endpoint (an inbound address), mail lands on it, and — depending on the endpoint’s policy — the agent either replies on its own or drafts a reply for a human to review. This guide walks the operator/integrator flow: create an endpoint, watch the review queue, read a message, approve/edit and send (or discard) the draft, assign mail that couldn’t be routed, and clear the quarantine. Two API surfaces are involved:
  • Endpoint admin (/v1/api/email/endpoints, /v1/api/email/workspace-mailbox) — manage the addresses. Every operation is admin-scoped.
  • Triage (/v1/api/email/...) — the read + action surface over inbound mail. Read/list operations require conversations:read, reply/discard require conversations:write, and the rest (assign, message/attachment content, quarantine) are admin-scoped.
All requests use the base URL https://api.hq.zone and a PAT bearer token:
export HQ_TOKEN="hq_pat_..."
Most of this feature is admin only. The scope is stated per endpoint below; where it says “admin”, a non-admin token is rejected. The triage list/badge endpoints (list_review, list_activity, get_badge) are scoped so a regular member sees only mail bound to them and the admin-only queues read empty.

1. Create an email endpoint (give an agent an address)

1

Create the endpoint

POST /v1/api/email/endpoints (admin) creates a custom inbox. The required fields are localpart and kind; an agent-handled inbox sets plane to agent and names a handler agent_id (the agent must belong to your workspace). A “light” quick-reply inbox is agentless — do not pass agent_id.
curl -X POST https://api.hq.zone/v1/api/email/endpoints \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "localpart": "sales.eu",
    "kind": "agent",
    "agent_id": "<AGENT_ID>",
    "plane": "agent",
    "reply_policy": "human_review",
    "sender_policy": "bound_users_only"
  }'
The response is the created endpoint (EndpointJson) with its id, the stored config, and per-endpoint counters.
2

Read back the full address

The endpoint object does not contain the full address by itself — it carries the localpart. To assemble the address, call GET /v1/api/email/endpoints (List endpoints, admin), which returns tenant_email_slug, domain, and outbound_mailbox_localpart alongside the endpoints:
curl https://api.hq.zone/v1/api/email/endpoints \
  -H "Authorization: Bearer $HQ_TOKEN"
# → { "tenant_email_slug": "...", "domain": "...", "outbound_mailbox_localpart": "team", "endpoints": [ ... ] }
The full address is <localpart>-<tenant_email_slug>@<domain>. So the inbox above resolves to sales.eu-<tenant_email_slug>@<domain>.
The localpart must be lowercase alphanumerics with . or - separators, start with an alphanumeric, and be at most 63 characters. Reserved localparts (such as postmaster, noreply) and the workspace mailbox localpart are rejected.

Endpoint behavior fields

create_endpoint and update_endpoint share the same config knobs. The validated enum fields:
  • plane — one of light, agent. light (the default) is the agentless quick-reply plane; agent hands the thread to the named agent.
  • sender_policy — one of bound_users_only, anyone, domain_allowlist, addr_allowlist.
  • reply_policy — one of agent_decides, always_reply, never_reply, human_review.
Other fields include instructions, allow_escalation, default_reply_lang, deliver_to, category_policy, spam_score_max, max_turns_per_sender_day, max_turns_per_day, enabled, and allow_patterns (the allowlist; supplying it replaces the set wholesale).
create_endpoint returns 402 if your plan’s email-inbox limit is reached, and 409 if the address (or an agent’s existing endpoint) already exists. The enum fields above are validated — an unknown value is a 400.

Manage existing endpoints

OperationMethod + pathScopeNotes
List endpointsGET /v1/api/email/endpointsadminEndpoints + counters + slug/domain.
Get endpointGET /v1/api/email/endpoints/{id}adminOne endpoint; unknown id → 404.
Update endpointPATCH /v1/api/email/endpoints/{id}adminOmitted fields keep current values; localpart/kind are fixed.
Delete endpointDELETE /v1/api/email/endpoints/{id}adminThe system workspace mailbox can’t be deleted (400).
# Pause an inbox without deleting it
curl -X PATCH https://api.hq.zone/v1/api/email/endpoints/<ENDPOINT_ID> \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": false}'

The workspace outbound mailbox

Every workspace has one HQ-managed outbound mailbox — the address all agents send from (is_system: true). It is never edited through the endpoint editor (that returns 400) and can’t be deleted. The only knob is its localpart, changed via Update mailbox:
curl -X PATCH https://api.hq.zone/v1/api/email/workspace-mailbox \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"localpart": "hello"}'
# → 200 { "ok": true }
The workspace mailbox never auto-replies, and that behavior is not configurable. An invalid or reserved localpart returns 400.

2. Mail arrives and the agent drafts

When mail lands on an endpoint, HQ mirrors it as an email_message and routes it. The message moves through a small handling-state machine. The exact state strings are:
  • unrouted — couldn’t be routed to a handler (admin owns this queue).
  • awaiting_review — the agent drafted a reply; a human must approve, edit & send, or discard it.
  • handled — terminal: replied, discarded, or assigned.
When the endpoint’s reply_policy keeps a human in the loop, the drafted reply lands in awaiting_review for you to act on. The badge and review queues count exactly the items needing a human.

3. The review queue

List review (GET /v1/api/email/review, scope conversations:read) returns the three actionable queues plus counts:
curl https://api.hq.zone/v1/api/email/review \
  -H "Authorization: Bearer $HQ_TOKEN"
The response (ReviewQueues) has:
  • awaiting_review — rows of drafted replies (scoped to the caller; admins see all). Each is capped at 200 items.
  • unroutedadmin-only; empty for a non-admin.
  • quarantinedadmin-only; empty for a non-admin.
  • counts{ awaiting_review, unrouted, quarantined }.
Each awaiting_review / unrouted row carries email_message_id, direction, handling_state, from_addr, to_addrs, subject, llm_category, llm_summary, agent_id, endpoint_id, endpoint_localpart, endpoint_plane, a conversation_id deep-link, and occurred_at.
For a one-number nav badge, call Get badge (GET /v1/api/email/badge, scope conversations:read). It returns count (the sum the caller can see) broken down into awaiting_review, unrouted, and quarantined. For the full inbound + outbound feed with pagination and filtering, use List activity (GET /v1/api/email/activity, scope conversations:read) — it accepts state (handled / awaiting_review / unrouted), endpoint_id, limit (1–200, default 50), offset, and sort (recent default, or oldest).

4. Read a message

Pick an email_message_id from the review queue and fetch the full message with Get message (GET /v1/api/email/messages/{id}/content, admin):
curl https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/content \
  -H "Authorization: Bearer $HQ_TOKEN"
Headers, decoded bodies, and the attachment list are fetched on demand from the email store (the raw message is never kept on HQ’s side). An unknown or cross-workspace id returns 404. To pull one attachment’s raw bytes, use Download attachment (GET /v1/api/email/messages/{id}/attachments/{attachment_id}/download, admin). It streams the bytes with the upstream Content-Type and Cache-Control: no-store; an attachment that doesn’t belong to the message returns 404.

5. Approve & send — or discard

1

Send the reply

Send reply (POST /v1/api/email/messages/{id}/reply, scope conversations:write) approves the drafted reply. The body (ReplyReq) is { "text": "...", "html": "..." }text is required and must be non-empty; html is optional. The UI pre-fills text with the draft so a human can edit before sending.
curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/reply \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"text": "Thanks for reaching out — here is the quote you asked for..."}'
# → 200 { "ok": true, "handling_state": "handled" }
The reply is sent as the handler agent on behalf of the bound user, and the item settles to handling_state: "handled".
2

Or discard the draft

Discard draft (POST /v1/api/email/messages/{id}/discard, scope conversations:write) drops the draft without sending and settles the item to handled:
curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/discard \
  -H "Authorization: Bearer $HQ_TOKEN"
# → 200 { "ok": true, "handling_state": "handled" }
Both actions require the item to be in awaiting_review. Acting on a message in any other state returns 400 (message is <state>, not awaiting_review). A regular member can act only on mail bound to them — someone else’s message returns 404 (so it can’t be probed). send_reply also returns 400 if the message has no handler agent or no bound user.

6. Assign unrouted mail

Mail that couldn’t be routed sits in the unrouted queue (admin-only). Assign email (POST /v1/api/email/messages/{id}/assign, admin) attaches a handler and re-drives it:
curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/assign \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"agent_id": "<AGENT_ID>"}'
# → 200 { "ok": true, "handling_state": "handled" }
The request body (AssignReq) is just { "agent_id": "..." }. The agent must belong to your workspace. The handler sets the message’s agent, re-emits the routed event so a turn runs, and — if the mail hit a handler-less mailbox endpoint — wires the chosen agent onto that endpoint so future mail routes without a human. The item settles to handled.
The message must be in unrouted — anything else returns 400 (message is <state>, not unrouted). An unknown agent, or a message with no bound user, also returns 400.

7. The quarantine path

Suspicious inbound mail (for example, unknown_sender or a spam-threshold hit) is held in quarantine instead of being routed. This is an admin-only surface; the pending items appear as the quarantined queue in list_review.
1

View the quarantined content

Get quarantine (GET /v1/api/email/quarantine/{id}/content, admin) fetches the raw message for inspection before you decide. Attachments aren’t materialized until accept, so the attachments list is empty here.
curl https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/content \
  -H "Authorization: Bearer $HQ_TOKEN"
Note the id here is the quarantine id, not an email_message_id. An unknown id returns 404.
2

Accept (optionally binding the sender)

Accept quarantine (POST /v1/api/email/quarantine/{id}/accept, admin) marks the item accepted. The body (AcceptReq) optionally carries bind_user_id — binding the sender to a workspace user so their future mail routes. A bind_user_id is required to clear an unknown_sender quarantine.
curl -X POST https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/accept \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"bind_user_id": "<USER_ID>"}'
# → 200 { "ok": true, "note": "Accepted and re-delivered to the agent." }
When the destination is now reachable (a freshly bound unknown_sender, or a spam_threshold item whose sender is already bound), HQ re-delivers this message so the agent acts on it immediately; otherwise it routes on the sender’s next mail. The response (ActionOk) returns ok: true and an optional explanatory note.
3

Or reject

Reject quarantine (POST /v1/api/email/quarantine/{id}/reject, admin) marks the item rejected — a terminal decision:
curl -X POST https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/reject \
  -H "Authorization: Bearer $HQ_TOKEN"
# → 200 { "ok": true }
Accept and reject act only on a pending quarantine. If no pending item with that id exists, both return 404. Accepting an unknown_sender without a bind_user_id only records the decision — the response note reminds you to pass bind_user_id to route the sender’s future mail.

Where it lands

Once an item is handled, it drops out of the review queue and the badge. It stays visible in the Activity feed, where you can filter by state or endpoint_id to audit what the agent and your reviewers did.