Docs · Reference
API reference
Every /v1 endpoint, with parameters, pagination, errors and limits.
Base URL: https://api.agentboxd.com. All /v1 routes need Authorization: Bearer <api_key>. The dashboard calls the same routes with its session instead; see Dashboard and account API.
The whole /v1 API is described in OpenAPI 3.1, generated from the routes and their validation, for Postman, Insomnia, client generators and agents. Every docs page is also available as markdown: add .md to its URL, as in /docs/api.md.
#Conventions
- Lists use cursor pagination:
?cursor=&limit=(max 100). Responses are{ data, next_cursor }. - Errors are always
{ "error": { "code", "message", "details"? } }. A refused send also hasdocs_url, its entry on Why was my send refused?. - Text can’t contain the NUL character (U+0000): a body field, key or query value with one gets
400 invalid_request. Received mail is stored without it in the subject, text, HTML, headers, names and file names; the original MIME (/raw) keeps it. - Send, reply, draft create and draft send honour an
Idempotency-Keyheader, so retries never send twice. - Times are ISO 8601 in UTC.
#API keys and permissions
Create keys on the API keys page. Each key has a scope (the whole workspace, or one inbox) and a set of permissions. Pick a preset or tick permissions one by one; the dashboard shows both on every key. The inbox, message, thread, webhook, list and overview pages of the dashboard also have a Use the API button with that page’s calls, pre-filled with the ids on screen, for curl, TypeScript, Python, MCP and a prompt for a coding agent.
| Preset | Permissions |
|---|---|
Full access (full) | All of them. Keys created before scopes existed are full-access workspace keys. |
Send & read (send_read) | inboxes:read, messages:read, messages:send, messages:write, drafts:read, drafts:write, attachments:extract, threads:read, search, contacts:read, knowledge:read, directory:read, calendar:read, calendar:write |
Read only (read_only) | Every *:read permission (including drafts:read), plus search and metrics:read |
Manage inboxes (manage_inboxes) | inboxes:read, inboxes:write, directory:write (their agent cards) |
Sign in only (sign_in) | inboxes:read, identity:sign, directory:read: the agent can prove who it is to apps that use Sign in with Agentboxd, and nothing else. |
The permissions are inboxes:read, inboxes:write, messages:read, messages:send, messages:write (labels, read state and the claim/ack queue), messages:release (release held mail to agents and read it with include_held; in no preset), drafts:read, drafts:write (sending or scheduling a draft also needs messages:send), attachments:extract (structured extraction from attachments; reading their text needs only messages:read), threads:read, search, contacts:read, contacts:write, knowledge:read, knowledge:write, webhooks:manage, domains:manage, lists:manage, metrics:read, identity:sign (mint identity tokens for an inbox), identity:manage (register sign-in apps, turn an inbox’s identity on or off), escalation:manage (human on call: who is alerted and when), workspace:emergency (the emergency stop), directory:read (resolve, verify, search and report agents in the directory) directory:write (create, edit, revoke and delete an inbox’s agent card), calendar:read (an inbox’s meetings) and calendar:write (answer, create, change and cancel meetings; each also needs messages:send). escalation:manage and workspace:emergency are only in the Full access preset. messages:release is in no preset: a person releases held mail, so give it only to a key a person uses, by ticking it when you create the key. Hosted MCP connections never get it. Keys created before the directory existed got the directory permissions only when their permissions matched a preset exactly; the same goes for the meeting permissions. A webhook with payload: "full" also needs messages:read for message and attachment events, drafts:read for draft events and calendar:read for meeting events; without them, use envelope payloads. A call without the permission it needs gets 403 insufficient_permissions with details.required. An inbox-scoped key sees only its inbox: other inboxes answer 404, workspace-wide lists and search are filtered to it, and it can’t create inboxes (403).
Expiry. A key can be given an end date when it is created: in the dashboard (never, 30, 90 or 365 days, or a date), with expires_at on POST /platform/api-keys and POST /v1/signup/agents, or npm run create-key -- --expires-in-days 90 on a self-hosted server. From that time on, every call with it gets 401 api_key_expired (with details.expired_at), the event stream refuses it, and SMTP submission answers 535. A connection that is already open is closed at its next check. The API keys page shows how long each key has left. About 7 days before a key expires, the workspace owners get one email about it, unless the key was made to last 7 days or less. To swap a key: create the new one, put it where the old one is used, then revoke the old one. Without expires_at a key never expires.
#Inboxes
| Method & path | Notes |
|---|---|
POST /v1/inboxes | { username?, display_name?, client_id?, metadata?, domain? }. Random readable username if omitted. Idempotent on client_id (200 with the existing inbox). domain is one of your verified domains (default homingbox.net; our other domains, such as agents.agentboxd.com, get no new inboxes: 400 invalid_domain). Send { ttl_seconds } (60–86 400) instead for a temporary inbox; username, domain and client_id are not allowed with it. card (the agent card fields) creates the inbox and its card in one call and returns it as card; it needs directory:write too, is checked before the inbox is created, and is refused on temporary inboxes (403 identity_unavailable). |
GET /v1/inboxes | List inboxes. Filter on your own fields with ?metadata.<key>=<value> (exact string match). Temporary inboxes are left out unless you pass ?include_temporary=true; ?temporary=true lists only them. Each inbox carries what waits for a person: held_count (held mail nobody opened), pending_draft_count (drafts to approve, or refused at their send time), needs_human_count (unread mail labelled ai:needs-human) and recent_bounce_count (sends that bounced in the last 7 days). |
GET /v1/inboxes/:id | One inbox, with its metadata and the same counts as the list. Every inbox has temporary (boolean) and expires_at (ISO time, or null). |
PATCH /v1/inboxes/:id | { display_name?, metadata?, ttl_seconds?, promotions_filter? }. Metadata is merged; a null value deletes the key. See Metadata. ttl_seconds extends a temporary inbox, counted from now, up to 24 hours after it was created. promotions_filter turns the Promotions & spam view on or off; null goes back to the default (on for role addresses such as hello@ and support@). |
DELETE /v1/inboxes/:id | Soft delete. The address is never reused. Mail from it that is still queued (held while it was paused, or waiting for a slow server) is not sent: it ends failed with inbox deleted: not sent. On a temporary inbox this wipes its messages, attachments and threads right away. |
POST /v1/inboxes/:id/pause | { reason? } (up to 500 characters). The kill switch: every send from the inbox is refused with 423 inbox_paused (send, reply, draft send, SMTP), and so is Sign in with Agentboxd for it. Inbound mail is still received and stored, but its message.received events and AI categorisation are held until you resume. Emits inbox.paused. Pausing a paused inbox changes nothing. |
GET /v1/inboxes/:id/forwarding | The inbox’s automatic forwarding addresses: { data: [{ id, address, status: "pending" | "confirmed", skip_flagged, confirmed_at, last_forwarded_at, forwarded_count }] }. Needs inboxes:read. See Automatic forwarding. |
POST /v1/inboxes/:id/forwarding | { address, skip_flagged? } → 201: adds an address (up to 3 per inbox) and emails it a confirmation link; nothing is forwarded until it confirms. Needs inboxes:write and messages:send. 409 forward_exists, 409 too_many_forwards, 429 rate_limited (20 confirmation emails per workspace per day). |
POST /v1/inboxes/:id/forwarding/:forwardId/resend | A new confirmation link for a pending address (409 already_confirmed once confirmed). Needs inboxes:write and messages:send. |
DELETE /v1/inboxes/:id/forwarding/:forwardId | Stops forwarding to that address. Needs inboxes:write. |
POST /v1/inboxes/:id/resume | Sending works again. The held events are emitted in the order the mail arrived, after inbox.resumed; the response is the inbox plus released_events. Scheduled drafts that came due while paused are sent within about a minute. |
#Automatic forwarding
An inbox can copy every email it receives to up to 3 outside addresses, for example so a person also sees what reaches hello@ in their own mailbox. Set it up in the dashboard (the inbox’s Settings → Forwarding) or with the routes above.
- Confirmed first. Adding an address emails it a confirmation link (valid 7 days, single use); nothing is forwarded until it is clicked, and workspace owners get an email when an address confirms.
- Only screened mail, when your workspace screens agent mail (
screened_only, on for new workspaces): forwarding follows the same rule as agents. Mail that passed its check is forwarded. Held mail is not: a warning, a check that failed (check_failed), was skipped over the quota (quota) or didn’t finish (check_timed_out). Nor is mail whose check is still running. When a person releases held mail, it is forwarded then (message.released). - Flagged mail stays behind by default: messages labelled
ai:phishing,ai:injection-risk,local:injection-riskordmarc-failare not forwarded whileskip_flaggedis on, unless a person released them. Bounces and mail stopped by your lists are never forwarded. Withscreened_onlyoff, mail whose check failed, was skipped or was still running after 5 minutes is forwarded. - No loops. Copies carry
Auto-Submitted: auto-forwardedand are labelledauto-forwardedin the inbox; mail that already carries that header, or comes from the forwarding address itself, is not forwarded again. - Ordinary sends. Each copy is a forward from the inbox (new thread, “Forwarded message” header, attachments included), so send limits, suppression, pause and the emergency stop apply. At most 500 copies per address per day.
- When. Right after categorisation, so the flags are known; at once when AI processing is off; after 5 minutes if categorisation never comes (with
screened_onlyon, only if it is screened by then); and when a person releases held mail.
#Emergency stop and human on call
| Method & path | Notes |
|---|---|
POST /v1/emergency-stop | { reason? } (up to 500 characters) stops every agent in the workspace: every send, reply, draft send and SMTP submission answers 423 workspace_stopped, identity tokens are refused, scheduled drafts wait and the claim queue returns nothing. Reads, inbound mail and webhooks keep working. Needs workspace:emergency and an org-wide key. Emits workspace.stopped; returns { org }. There is no API resume: an owner resumes in the dashboard. See Emergency stop. |
GET /v1/escalation | { contacts: [{ email, status: "pending" | "confirmed", confirmed_at }], triggers, delivery, max_per_hour, quiet_hours, include_excerpt }. Needs escalation:manage and an org-wide key. See Human on call. |
PUT /v1/escalation | Same fields; the ones you leave out keep their value. contacts (up to 5) replaces the list: new addresses get a confirmation email and receive nothing until confirmed. triggers: needs_human, phishing, blocked, draft_failed, draft_created, emergency_stop, held (booleans). delivery: immediate or digest. max_per_hour: 1–60. quiet_hours: { start: "22:00", end: "07:00", timezone: "Europe/Paris" } or null. include_excerpt: add the first 500 characters of the text. At most 20 confirmation emails a day (429 rate_limited). |
GET /v1/inboxes/:id/escalation | { override, contacts, triggers }: the inbox’s own on-call people and triggers (triggers: null = the workspace’s). Needs escalation:manage; an inbox-scoped key may manage its own inbox. |
PUT /v1/inboxes/:id/escalation | { override?, contacts?, triggers? }. With override: true, alerts about this inbox go to its own confirmed contacts instead of the workspace’s. |
Every inbox has status (active or paused), paused_at and paused_reason. A paused inbox stays readable with every key. Keys scoped to one inbox with inboxes:write can pause and resume their own inbox (they could already delete it). Mail already queued waits too, and goes out within a minute of the resume. Mail already handed to the receiving server can’t be recalled.
#Messages
| Method & path | Notes |
|---|---|
POST /v1/inboxes/:id/messages/send | { to, cc?, bcc?, subject, text?, html?, attachments?: [{ filename, content_type, content_base64 }], labels?, send_at?, data?, type?, agent_signature? }. Returns 202 with status: "queued", channel and delivery. data (a JSON object or array, at most 64 KB) and type (message, task, event) make it an agent message; text, html or data is required. A recipient can be a handle such as @acme/billing (422 handle_not_found). agent_signature is the agent’s own author signature (422 invalid_agent_signature; not with send_at). Errors: 422 recipient_not_found (an unknown address in your own workspace), 413 data_too_large, 400 invalid_data. With send_at (ISO, 1 minute to 30 days ahead) nothing is sent now: it creates a scheduled draft and returns 202 with the draft instead of a message. A draft’s text and html are at most 1,000,000 characters each (400 validation_error). |
POST /v1/inboxes/:id/messages/:messageId/reply | { text?, html?, attachments?, reply_all?, send_at?, data?, type?, agent_signature? }. Sets In-Reply-To and References, adds Re:, stays in the thread. send_at and agent_signature work as on send. |
GET /v1/inboxes/:id/messages | Filters: labels=a,b (must have all), is_read, direction, channel (agent, email, mixed), type (message, task, event), screening (screened, pending, held, released: received mail in that screening state), status (queued, sent, delivered, bounced, complained, failed, received; e.g. direction=outbound&status=bounced), include_unscreened, include_held, promotions (exclude leaves out the inbox’s promotions, only lists just those; see Local checks). In a screening workspace, API keys get held and unscreened mail without its content (what an API key gets). |
GET /v1/inboxes/:id/messages/wait | Long-poll. timeout (1–60 s, default 30), since (ISO, default now), from (the sender’s address, matched exactly; a domain, matching it and its subdomains; or a part of the address: never the display name), subject (a case-insensitive substring), direction (default inbound), channel, type, include_unscreened, include_held (true/false, see Screening). Returns { data: Message | null }: the oldest message after since, or null on timeout. When the workspace screens agent mail, inbound mail is returned once it is screened, never while it is held. include_unscreened + include_held together = everything. |
GET /v1/inboxes/:id/verification | Long-poll for a login code or magic link. timeout, since, from (a sender address or a domain only, as on messages/wait: github alone is refused with 400, since it is also part of addresses anyone can register), include_unscreened, include_held, include_unauthenticated. Returns the newest matching message’s code, link, confidence and auth_failed, or null. A code or link from mail whose sender failed authentication (DMARC, or SPF without an aligned DKIM pass) is skipped unless include_unauthenticated=true. In a screening workspace a code or link from held or unscreened mail is not returned unless asked for. |
GET /v1/messages | Messages of every inbox the key can see, newest first (an inbox-scoped key: its inbox only). Each message has its inbox_id. The filters of GET /v1/inboxes/:id/messages except promotions, the same cursor paging, and the same screening of held and unscreened mail. SDK messages.listAll() / messages.list_all(), MCP list_messages without inbox_id. |
GET /v1/messages/:id | One message. include_unscreened, include_held: see what an API key gets. Reading never changes is_read: set it with PATCH or batch-update. (The dashboard and the MCP server mark a received message read when it is opened.) |
POST /v1/inboxes/:id/messages/:messageId/forward | { to, cc?, bcc?, text?, html?, include_attachments?, labels? }. Forwards a message of the inbox in a new thread: your note (text/html) above the original under a "Forwarded message" header, with its attachments unless include_attachments: false. Send limits and checks apply as for a send; Idempotency-Key works. |
PATCH /v1/messages/:id | { add_labels?, remove_labels?, is_read? }. 422 reserved_label for labels Agentboxd sets. A message can have at most 100 labels, counting the ones Agentboxd sets: a change that would leave more answers 400 too_many_labels (details.max) and changes nothing. Removing labels always works. |
POST /v1/messages/:id/release | Releases a held message to agents: claim and wait return it from now on. Returns the message with screening.state: "released". Safe to repeat. Emits message.released (who, when, and why it was held). 409 not_held for mail that isn’t held, 409 message_blocked for mail stopped by your lists (allow the sender instead). Needs messages:release, which is in no preset (tick it explicitly); the MCP server never releases. |
POST /v1/messages/batch-get | { ids } (1–100). { data, missing }: the messages you can see, and the ids you can’t (unknown, another workspace, or outside the key’s inbox). |
POST /v1/messages/batch-update | { ids, add_labels?, remove_labels?, is_read? }. The same change on up to 100 messages. { data, missing }, as for batch-get. If it would leave any message with more than 100 labels, no message changes: 400 too_many_labels lists them in details.ids. |
GET /v1/messages/:id/raw | Original MIME (message/rfc822, download only). |
POST /v1/messages/:id/draft-reply | { instructions?, save? } (instructions up to 2,000 characters). Returns { text, citations: [{ knowledge_id, title }], model }. Never sends. With save: true the reply is also stored as a draft and returned in draft (needs drafts:write too). Needs AI processing full; see Reply drafts. |
GET /v1/attachments/:id | Streams the file (download only, nosniff, sandbox CSP). |
GET /v1/messages/:id/attachments/:attachmentId/text | ?offset=&max_chars= (up to 200,000). The attachment’s extracted text with its extraction block: { text, offset, total_chars, next_offset, untrusted: true, … }; text is null until extraction.status is done. See Document extraction. |
POST /v1/messages/:id/attachments/:attachmentId/extract | { schema: "invoice" | "receipt" | "tax_form" | {JSON Schema}, instructions? } → { schema, data, model, repaired, truncated, untrusted: true }, validated against the schema. Needs attachments:extract and AI processing full; counts toward structured_extractions_per_month. |
#Claim and ack (work queue)
Webhooks, the stream and wait hand a message to your agent, but nobody notices if the agent dies halfway through the work. The claim/ack queue does: claim leases inbound messages, your agent does the work, then acks each one. A lease that runs out without an ack makes the message claimable again, so delivery is at-least-once: make the work idempotent (the delivery_count tells you it is a redelivery). It works for every inbound message, alongside webhooks and the stream, which keep working as before.
| Method & path | Notes |
|---|---|
POST /v1/inboxes/:id/messages/claim | { limit?, lease_seconds?, consumer?, wait?, enriched?, analysis?, include_unscreened?, include_held?, since?, type?, channel? } → { data: [{ lease_id, lease_until, delivery_count, message }], paused }. Oldest first. limit 1–50 (default 10), lease_seconds 30–3600 (default 300), wait long-polls 0–30 s (default 0), consumer is a name stored with the lease (up to 100 characters). When the workspace screens agent mail (the default for new workspaces), only screened and released mail is claimable; include_unscreened: true adds mail whose check hasn’t finished, include_held: true adds held mail (needs messages:release); both together = everything. enriched: true only returns messages whose AI categorisation is done, skipped or failed (or that a person released), so the injection score is there before you act; a check that never finishes keeps the message back until someone releases it. analysis: "completed" is stricter: only messages whose injection and phishing check completed; skipped and failed ones are never returned to this claim (422 analysis_unavailable when the workspace doesn’t check mail). Needs messages:write and messages:read. |
POST /v1/messages/:id/ack | { lease_id, mark_read? } → { id, acked_at }. The message is never claimed again. Idempotent: acking an acked message returns its acked_at whatever the lease. It doesn’t touch is_read unless mark_read: true. |
POST /v1/messages/:id/nack | { lease_id, delay_seconds? } (0–3600, default 0) → { id, delivery_count, available_at, dead_letter }. Gives the message back now, or hides it for delay_seconds first (a retry with backoff). |
POST /v1/messages/:id/extend | { lease_id, lease_seconds? } (30–3600, default 300) → { id, lease_id, lease_until }. The heartbeat for long work: the lease then runs until now + lease_seconds. |
- The first claim starts the queue. Mail that arrived before an inbox’s first claim is not claimable, so turning the queue on doesn’t hand an agent its whole history. Pass
since(ISO time) on that first claim to start earlier. - Concurrent consumers never get the same message while its lease is live.
ack,nackandextendmust present thelease_idof the current lease; one that ran out and was claimed again (or released, or dead-lettered) answers409 lease_expired. An ack after the lease ran out still succeeds as long as nobody claimed the message again. - Dead letters. A message claimed 10 times without an ack is labelled
queue:dead-letterand never claimed again. It stays readable: list them with?labels=queue:dead-letter. - Paused inboxes return
{ data: [], paused: true }: a claim is work, and pausing holds work. Leases you already hold can still be acked, nacked or extended, so a stopping agent can finish cleanly. Mail received while paused becomes claimable on resume. - Deleted mail. Leases never hold back retention or inbox deletion.
ack,nackandextendon a message that is gone answer200 { id, gone: true }, so consumer loops don’t crash on it. - Blocked mail (receive lists) and outbound mail are never claimable. There are no events per claim or ack;
GET /v1/metricshas the queue depth, in-flight leases and dead letters per inbox. The SDKs’consume()helper runs the whole loop for you (claim, extend while working, ack on success, nack with backoff on errors).
#Drafts
A draft is a message waiting for review, approval or its send_at. Statuses: draft, scheduled, sending, sent, failed, cancelled. Only draft and scheduled drafts can be edited. See Drafts and scheduled send.
| Method & path | Notes |
|---|---|
POST /v1/inboxes/:id/drafts | { to?, cc?, bcc?, subject?, text?, html?, attachments?, metadata?, labels?, reply_to_message_id?, thread_id?, reply_all?, send_at? }. Everything is optional. With reply_to_message_id (or thread_id: its latest message) recipients and the Re: subject are filled like a reply. 201 with the draft. Needs drafts:write, plus messages:send when send_at is set. |
GET /v1/inboxes/:id/drafts | ?status=draft,scheduled&thread_id=&cursor=&limit=, newest first. |
GET /v1/drafts | Every inbox’s drafts: ?inbox_id=&status=&thread_id=&cursor=&limit=. |
GET /v1/inboxes/:id/drafts/:draftId | One draft. |
PATCH /v1/inboxes/:id/drafts/:draftId | Same fields as create, except the reply fields. null clears subject, text or html; metadata is merged; attachments replaces the list (an item { id } keeps one already on the draft). send_at schedules or reschedules, send_at: null unschedules. 409 draft_not_editable unless the draft is draft or scheduled. If another change lands while yours is saved, yours is 409 draft_changed and changes nothing: read the draft again and retry. |
DELETE /v1/inboxes/:id/drafts/:draftId | 204. Not while it is sending. |
POST /v1/inboxes/:id/drafts/:draftId/send | Sends now through exactly the same checks as send and reply (lists, suppressions, quotas, the burst limit, daily caps). 202 { draft, message }. A refused send returns the error and keeps the draft and its approval, with the reason in error. Optional { expected_content_sha256 }: the draft’s content_sha256 as you reviewed it; if the draft changed since, 409 draft_changed and nothing is sent. Needs drafts:write and messages:send. |
POST /v1/inboxes/:id/drafts/:draftId/schedule | { send_at, expected_content_sha256? } (send_at 1 minute to 30 days ahead, else 400 invalid_send_at). 200 with the draft, now scheduled. expected_content_sha256 works as on send. Needs drafts:write and messages:send. |
POST /v1/inboxes/:id/drafts/:draftId/cancel | 200 with the draft, now cancelled. A scheduled send will not happen. |
#Meetings
An inbox reads the meeting invites it receives and sends real ones. Each meeting is an event: one the inbox was invited to (role: "attendee", read from the invite’s calendar file) or one it organizes (role: "organizer"). Answers, invites, changes and cancellations are standard calendar messages (iTIP), so Google Calendar, Outlook and Apple Calendar show and update them as usual. Every call that sends goes through the same checks as a send (lists, suppressions, quotas, daily caps) and needs messages:send as well; with draft: true it saves a draft for a person to approve instead, and needs drafts:write. Meetings are in beta: outside it every call answers 404 calendar_not_enabled; write to hello@agentboxd.com to join.
| Method & path | Notes |
|---|---|
GET /v1/inboxes/:id/calendar/events | ?from=&to=&status=&role=&response=&message_id=&expand=&cursor=&limit=, soonest first. By default the meetings not over yet (from = now); a recurring series is listed once, whatever its first date (recurrence). expand=occurrences lists a series as its occurrences between from and to instead (at most 2 years; to defaults to from + 2 years), with truncated in the response; see Recurring series. message_id lists what one invite message created or changed. Needs calendar:read. |
GET /v1/calendar/events | The same across every inbox the key can see. |
GET /v1/calendar/events/:id | One meeting: title, description, location, conference ({ provider, url }, only for links on known video hosts), start, end (UTC), all_day, time_zone, recurrence ({ rrule, rdates, exdates } for a series, else null), organizer, organizer_verified, attendees (with each one’s partstat), response (the inbox’s own answer: needs_action, accepted, declined, tentative), proposals (new times attendees proposed), message_id and the invite’s screening. |
POST /v1/calendar/events/:id/respond | { response: "accepted" | "declined" | "tentative", comment?, propose?: { start, end }, draft? } answers an invite. The answer goes to the organizer only, never to Reply-To or the other guests, in the invite’s thread; propose suggests another time instead (a COUNTER, plus the time in words). 202 { event, message }; with draft: true, 201 { event, message: null, draft } and the answer counts once the draft is sent. 422 calendar_reply_not_allowed for the inbox’s own meetings, published entries and cancelled meetings; 409 message_withheld while the invite is held. |
POST /v1/inboxes/:id/calendar/events | { title, start, end? | duration_minutes?, all_day?, time_zone?, attendees: [{ email, name?, optional? }], description?, location?, conference_url?, recurrence?: { rrule }, message? } sends an invitation to every attendee (at most 50). start and end are ISO 8601 times with an offset, or dates (2026-10-15, end the day after the last day) with all_day: true; default length 30 minutes. time_zone (IANA, like Europe/Paris) is how the email words the time, and a series is sent in it so it keeps its local time when the clocks change; a single meeting is sent in UTC. recurrence.rrule: FREQ DAILY to YEARLY, at most 500 occurrences. message is a note above the meeting details. 201 { event, message }; with draft: true, 201 { event: null, message: null, draft }: the meeting exists once the draft is sent, with the id in draft.calendar.event_id. Idempotency-Key works as on send. |
PATCH /v1/calendar/events/:id | Changes one of the inbox’s meetings: title, start, end or duration_minutes, time_zone, attendees (replaces the list), description, location, conference_url (null clears the last three), message, draft. Everyone on the list gets the new version; attendees taken off it get a cancellation. A new time or place asks everyone to answer again. A new time_zone moves a series to that zone’s clock. 422 calendar_not_organizer for an invite the inbox received; 409 calendar_event_changed if another change landed first. |
POST /v1/calendar/events/:id/cancel | { message?, draft? } cancels one of the inbox’s meetings for everyone. Cancelling a cancelled meeting changes nothing and sends nothing (message: null). With draft: true (here and on PATCH), 201 { event, message: null, draft }. |
- When invites are read. Once the message’s screening is settled (the invite’s text is screened like any attachment), the calendar file becomes an event and
calendar.invite.receivedfollows. Updates and cancellations from the organizer change it (calendar.event.updated,calendar.event.cancelled); a new time or place setsresponseback toneeds_action. Older versions (a lower SEQUENCE) are ignored. - Who an invite is from.
organizer_verifiedis true when the sender passed DMARC and is the organizer, someone at the organizer’s own domain (not a shared provider such as gmail.com, nor our own inbox domain), or a calendar service that sends invites for its users (Google Calendar, Apple, Calendly, Cal.com, Zoom). A message that isn’t verified can’t change or cancel a verified meeting. Answers to the inbox’s own meetings count only from the attendee they answer for (calendar.response.received). - Held invites. In a workspace that screens agent mail, a meeting from a held or unscreened invite comes without its content for API keys, as held mail does: times, addresses and state stay,
titlereads[held: …], the rest is withheld andwithheldsays why. It can’t be answered until a person releases the invite (include_unscreenedandinclude_heldwork as on messages). - Nothing happens on its own. The inbox never answers an invite unless an agent or a person calls respond.
- On the message. A message that carries a meeting has
calendar: { event_id, method }: an invite once it is read (sonullinmessage.received;calendar.invite.receivedfollows with itsmessage_id), and every answer, invitation, change and cancellation the inbox sends.event_idis the meeting it created or changed,nullwhen it changed none (an older version, a sender who can’t speak for the organizer). Other messages havecalendar: null, and so does a message withheld by screening, like its files. - Approval drafts. With
draft: true, respond, create, PATCH and cancel send nothing: they save the message as a draft, and the meeting changes when a person (or a key withmessages:send) sends it, in the same step that stores the message. If the meeting changed in between, that send is refused with409 calendar_event_changedand nothing goes out. A key withdrafts:writebut notmessages:sendgets403 insufficient_permissionswithoutdraft: true, as on any send. See Meeting drafts.
Recurring series
A series is one event with a recurrence: its RRULE, extra dates (rdates) and left-out dates (exdates). An occurrence the organizer moved, changed or cancelled is a row of its own, with recurrence_id (its original start). With expand=occurrences the list gives each occurrence between from and to: the occurrences of the rule, with their own start, end and recurrence_id and the series’ id (respond, PATCH and cancel act on the whole series); the rows of their own in their place; and single meetings. Each has series_id (null for a single meeting). A series repeats on its own clock, so a weekly 09:00 meeting stays at 09:00 across a daylight saving change: in the time zone the invite defined, or its IANA zone. A series the inbox organizes with a time_zone is sent in that zone, with the zone’s rules in the invitation, so it stays at the same local time for every guest and in this list; one without a time_zone repeats at the same UTC time, as its invitation says. At most 500 occurrences of one series per list, and a rule too costly to follow is cut: then truncated is true; ask for a shorter window.
#Threads and search
| Method & path | Notes |
|---|---|
GET /v1/threads | Threads across every inbox the key can see (a supervisor’s view), newest activity first. Filters, all optional and combined: inbox_id, labels (comma-separated, all required), before / after (ISO time of the latest message), participant and subject (case-insensitive substrings), ascending=true, include_blocked=true, plus limit and cursor. |
GET /v1/inboxes/:id/threads | One inbox’s threads, newest activity first, with the same filters. |
GET /v1/threads/:id | The thread plus its messages in order. Threads carry labels and metadata. At most 200 messages: the newest ones, oldest first (?limit= for fewer). When there are older ones, next_cursor is set: pass it as ?cursor= to get the page before. |
PATCH /v1/threads/:id | { metadata?, add_labels?, remove_labels? }. Metadata is merged; null deletes a key. At most 100 labels, as for messages (400 too_many_labels). |
DELETE /v1/threads/:id | Deletes the thread with all its messages, attachments and extracted text, and the events that carry copies of them; their pending webhook deliveries are cancelled (messages:write). { deleted: true, messages, attachments }. 409 message_sending while a message in it is still going out; 503 storage_unavailable if stored mail could not be removed (nothing is deleted then; try again). |
GET /v1/search?q=&inbox_id= | Postgres full-text search with web-search syntax ("exact phrase", -exclude, or), ranked, with a snippet. |
#Contacts
| Method & path | Notes |
|---|---|
GET /v1/contacts | ?q=&label=&kind=&domain=&cursor=&limit=. Newest last_seen_at first. q matches address or name. kind is one or more of person, automated, sales, unknown, comma-separated (every kind when left out). domain is the part after the @. ?metadata.<key>=<value> filters. |
GET /v1/contacts/domains | ?kind=&limit= → { data: [{ domain, contacts, last_seen_at }] }: contacts counted per domain, most first, free-mail providers (Gmail, Outlook, Yahoo, iCloud, Proton…) left out. limit up to 200 (default 50). |
GET /v1/contacts/:id | The contact plus recent_threads (up to 10, across all inboxes). |
GET /v1/contacts/by-address/:address | Look a contact up by email address. 404 if you have never exchanged mail with it. |
PATCH /v1/contacts/:id | { name?, notes?, metadata?, add_labels?, remove_labels?, kind? }. Notes up to 10,000 characters. A name or kind set here is never changed by later mail. At most 100 labels, as for messages (400 too_many_labels). See Contacts and memory. |
DELETE /v1/contacts/:id | Deletes the contact → 204. Its messages stay. ?forget=true also stops mail from the address creating it again (the mail itself is not erased). Needs a workspace-wide key (inbox-scoped keys get 403). |
GET /v1/suppressions | ?reason=bounce|complaint|manual&q=&cursor=&limit= → { data: [{ address, reason, created_at, expires_at }], next_cursor }, newest first. Your workspace’s suppression list: addresses that bounced (bounce) or reported your mail as spam (complaint). Your sends to them are refused (422 recipient_suppressed). Only rows that apply now; other workspaces’ lists are never shown. contacts:read, workspace-wide keys only. |
DELETE /v1/suppressions/:address | Removes the address from your list, so you can write to it again → the removed row. A bounce or complaint needs ?confirm=true (409 confirmation_required otherwise): sending again to an address that bounced or complained hurts your deliverability and can get sending paused. 404 when it isn’t on your list. Emits suppression.removed (who removed what). contacts:write, workspace-wide keys only. |
POST /v1/contacts/delete | { kind?: ["automated", …], domain?, forget?, dry_run? } (kind, domain or both) → { matched, deleted, forgotten, has_more, dry_run }. Deletes up to 1,000 matching contacts per call; has_more means call again. dry_run: true only counts. Workspace-wide keys only. |
#Knowledge
| Method & path | Notes |
|---|---|
GET /v1/knowledge | ?inbox_id=&cursor=&limit=. Items have an excerpt (up to 200 characters) instead of the body. |
POST /v1/knowledge | { title, body, inbox_id? }. Body up to 200,000 characters of plain text or markdown. No inbox_id means all inboxes. 201 with the document. |
GET /v1/knowledge/:id | One document, with its body. |
PATCH /v1/knowledge/:id | { title?, body?, inbox_id? }. |
DELETE /v1/knowledge/:id | Deletes the document. |
GET /v1/knowledge/search | ?q=&inbox_id=&limit=. Ranked full-text search. With inbox_id, workspace-wide documents are included. Returns { data: [{ id, title, inbox_id, rank, snippet }] }. |
#Webhooks
| Method & path | Notes |
|---|---|
POST /v1/webhooks | { url, events?, inbox_ids?, enabled?, secret?, payload?: "full" | "envelope", headers? }. url must be a public HTTPS endpoint (422 invalid_webhook_url otherwise). events lists each event type at most once; repeats are dropped. payload: "full" sends message and draft content, so it needs the key’s messages:read for message and attachment events and drafts:read for draft events (403 insufficient_permissions otherwise). Without payload, a key that lacks them gets envelope. The secret is returned only here. headers: up to 10 extra headers sent with every delivery, e.g. { "Authorization": "Bearer …" } for your own check next to the signature. A value is one line of printable ASCII or Latin-1 characters, so é works and € doesn’t (400 validation_error). Values are stored encrypted and never returned (webhooks show header_names); Host, Content-Type and our X-Mailroom-* headers can’t be set. On PATCH, headers replaces the set and null clears it. A workspace can have up to 20 webhooks (409 webhook_limit_reached); one webhook can take any number of event types and inboxes. |
GET /v1/webhooks · GET /v1/webhooks/:id | Returns secret_hint, never the secret, and stats: deliveries_24h, failed_24h, error_rate_24h, last_success_at, last_failure_at. |
PATCH /v1/webhooks/:id · DELETE /v1/webhooks/:id | Change events, scope, payload or enabled; delete. Moving a full-payload webhook to another url, switching it to full or adding events needs the same permissions as creating it. |
POST /v1/webhooks/:id/rotate-secret | { secret? }: replaces the signing secret with yours (16–256 characters) or a generated one, and returns it once. See Rotate the secret. |
POST /v1/webhooks/:id/test | Sends a webhook.test event. |
GET /v1/webhooks/:id/deliveries | ?status=pending|succeeded|failed&cursor=&limit=, newest first: { data: [{ id, event_id, event_type, status, attempts, last_status_code, last_error, last_response_ms, next_attempt_at, last_attempt_at, redelivered_at, created_at }], next_cursor }. Each row is one event sent to this webhook; attempts is the number of its latest attempt, last_response_ms how long that attempt took. Deliveries are kept as long as their event (message, attachment, draft, agent and test events follow your retention). |
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver | Sends that delivery again: the same payload, signed again with a fresh timestamp, one attempt (no retries) → 202 with the delivery, status: "pending". 409 delivery_pending while it is still being retried, 409 webhook_disabled while the webhook is paused, 404 when it isn’t this webhook’s. |
POST /v1/webhooks/:id/redeliver | { event_id }: the same, found by event id. |
GET /v1/webhooks/events | Public. Every event type with a description and an example payload. Webhooks accept payload: "envelope" for ids, addresses, subject and labels only: no bodies or AI content. |
#Realtime stream
| Method & path | Notes |
|---|---|
GET /v1/stream | WebSocket (wss://). The webhook events, pushed live: subscribe with inbox_ids?, event_types?, payload? and since? to replay what you missed (last hour, up to 1000 events). Auth: Authorization: Bearer on the upgrade, or a stream token. Needs messages:read; without drafts:read, draft events arrive as envelopes. See Realtime events. |
POST /v1/stream/token | → 201 { token, expires_at, url }: a single-use URL for GET /v1/stream, valid 60 seconds, for browsers. Keys are never accepted in the URL. |
To send over SMTP instead of the API, log in to smtp.agentboxd.com (587 with STARTTLS, or 465) with an API key as the password. See Send with SMTP.
#Allow and block lists
| Method & path | Notes |
|---|---|
GET /v1/lists | ?inbox_id=&direction=&kind=. Needs the lists:manage permission. |
POST /v1/lists | { inbox_id?, direction: "receive" | "send" | "reply", kind: "allow" | "block", pattern }. A pattern is an address (a@b.com), a domain (@b.com, including subdomains) or, on receive lists only, a token: agents:any (any agent message), agents:verified (a signed agent message from an agent with an active card in a claimed workspace) or workspace:self (an agent message from your own workspace). Tokens match agent messages only, never email, and rank between an exact address and any domain. Without inbox_id it applies workspace-wide. Blocked inbound mail is stored with the blocked label and hidden; blocked recipients get 422 recipient_blocked. |
DELETE /v1/lists/:id | Remove an entry. |
The dashboard manages the same lists under Allow & block, and an inbox’s Show blocked filter lists the mail they stopped (?include_blocked=true).
#Agent cards and directory
An inbox (or an identity without a mailbox) can have an agent card: what other agents see when they look it up. No inbox has one until you create it, a new card is private, workspace cards are listed in your own workspace, and public cards (opt-in) in the public directory, Rollcall. The directory routes answer 503 directory_disabled while the server hasn’t turned the directory on (GET /v1/account → agent_messaging.directory_enabled); cards work either way.
| Method & path | Notes |
|---|---|
GET /v1/inboxes/:id/agent | The bundle: { inbox, identity: { enabled, sign_in_ready }, card | null, keys: { server: { kid, jwks_uri }, agent: [key] }, routing, visibility, status: "no_card" | "active" | "revoked" | "suspended", assurance, badges, domain, handle }. keys.agent lists the keys the agent holds; badges is ["domain_verified"] for an address on a custom domain your workspace verified. Needs inboxes:read. |
PATCH /v1/inboxes/:id/agent | { name?, description?, capabilities?: { skills?, accepts_types?, languages?, input_modes? }, documentation_url?, visibility?: "private" | "workspace" | "public", routing?: "relay", handle?: string | null, indexable?: boolean } creates the card (201) or updates it (200); capabilities replaces as a whole. Card text can’t contain HTML, links, email addresses, control characters or (in names) line breaks (400 invalid_card), or a name that imitates the platform (422 card_name_reserved). public lists the agent in the public directory (Rollcall): it needs a claimed workspace and an org-wide key or the dashboard (403 insufficient_permissions for inbox-scoped keys), and counts toward the plan’s public listings (403 plan_limit_public_listings). 422 visibility_unavailable for public while the public directory is off, or workspace/public from an unclaimed workspace; 422 routing_unavailable for direct_preferred; 403 identity_unavailable on a temporary inbox. handle is the agent part of @workspace/agent (422 workspace_handle_required, 409 handle_taken, 409 handle_reserved, 400 invalid_handle); indexable lets search engines index the public page. Needs directory:write; inbox-scoped keys edit their own card. |
GET /v1/inboxes/:id/agent/oasf | The card as an OASF record (schema 0.7.0), any visibility. 404 not_found without a card. Needs inboxes:read. |
GET /v1/inboxes/:id/agent/keys | { data: [key] }: every key the agent registered, any status, newest first. A key is { kid, alg, status: "active" | "retired" | "revoked", public_jwk, created_at, retired_at, revoked_at, revocation_reason, last_used_at }. Needs inboxes:read. |
POST /v1/inboxes/:id/agent/keys | { public_jwk, proof } → 201 key. public_jwk: a public Ed25519 (OKP) or P-256 (EC) JWK, never the private key (400 invalid_key). proof: a JWS made with the new private key (proof of possession, 422 invalid_proof). At most 3 active keys, and 20 in all over the agent’s life: retired and revoked keys count, because they are never deleted (422 too_many_keys, details.counted is active or all). A key is registered once per agent (409 key_exists). Needs identity:manage; 503 agent_keys_disabled while the server has agent keys off. |
POST /v1/inboxes/:id/agent/keys/:kid/retire | Rotation: the key signs nothing new; messages it signed before still verify. Idempotent. Needs identity:manage. |
POST /v1/inboxes/:id/agent/keys/:kid/revoke | { reason?, since? }: compromise. Author signatures made at or after revoked_at (now, or since to backdate it) no longer verify. Idempotent. Needs identity:manage. |
DELETE /v1/inboxes/:id/agent | Deletes the card and its content at once (204); the inbox is unchanged. Emits agent.deleted. |
POST /v1/inboxes/:id/agent/revoke | { reason? } (up to 500 characters). The agent stops signing its agent messages (recipients see agent.verified: false, reason: "revoked") and directory verify answers revoked. Mail keeps flowing both ways. Idempotent. Emits agent.revoked. |
POST /v1/inboxes/:id/agent/restore | Undoes a revoke. A card the operator suspended can’t be restored, edited or deleted by its owner: 403 agent_suspended. |
GET /v1/directory/resolve | ?address= or ?handle=@acme/billing → { card }. Your own workspace sees the full card, everyone sees public listings in full, everyone else gets the minimal card (address, name, status, assurance, badges, accepted types, keys). Other workspaces resolve a handle only for public listings. A renamed handle answers with the new card and redirected_from. Every miss is the same 404 agent_not_found. Needs directory:read; 120 a minute per key. |
POST /v1/directory/verify | { signature } (a message’s agent.signature, or its author.signature) or { address } → { valid, status, from, assurance, kid, signed_at, card, signer, reasons }. Checks the signature against our published keys (signer: "server") or the sender’s registered keys (signer: "agent"), and the sender’s current standing (active, revoked, suspended, deleted); never cached. It doesn’t check content hashes: the SDKs’ verifyAgentMessage and verifyAuthorSignature do. 300 a minute per key. |
GET /v1/directory/search | ?q=&capability=&type=&scope=&limit=&cursor= → { data: [card], next_cursor }: active workspace cards of your workspace, or with scope=public the public directory. limit 1–50 (default 20). 30 a minute per key. |
GET /v1/directory/handle | { handle, previous: [{ handle, redirect_until }] }: your workspace handle (@acme) and renamed ones that still redirect. Needs directory:read. |
PUT /v1/directory/handle | { handle } claims or renames the workspace handle. The old one redirects for 90 days. Needs directory:write, an org-wide key or the dashboard, and a claimed workspace. 400 invalid_handle, 409 handle_taken, 409 handle_reserved. |
DELETE /v1/directory/handle | Releases the workspace handle. Nobody can claim it again; it doesn’t redirect. |
POST /v1/directory/reports | { address, reason: "spam" | "impersonation" | "malicious" | "illegal" | "other", details?, message_id? } → 202 { received: true }. details is plain text without control characters. 10 per workspace a day. The agent’s owner gets agent.reported with the reason only, never who reported. |
Lookups that miss count against your workspace (30 a minute, 500 a day); past that every resolve answers 429 directory_rate_limited, so the directory can’t be used to find out which addresses exist.
Public directory (no API key)
Publicly listed agents are readable without authentication, 60 calls a minute per IP address (30 misses a minute). Answers are cacheable for 5 minutes (ETag, CORS open). Every miss, including cards that are not public, pending review, hidden or revoked, is the same 404 agent_not_found; 503 public_directory_disabled while the server has the public directory off.
| Method & path | Notes |
|---|---|
GET /v1/public/agents | ?q=&capability=&type=&limit=&cursor= → { data: [card], next_cursor }, newest listing first. |
GET /v1/public/agents/:address | { card }: the public card, with handle, badges and urls (web page, A2A card, OASF record, keys). |
GET /v1/public/agents/:address/agent-card.json | The A2A agent card (A2A 1.0), signed with our directory key (signatures, verifiable with agent-keys.json). |
GET /v1/public/agents/:address/oasf.json | The OASF record. |
GET /v1/public/agents/:address/keys.json | { keys: [jwk] }: the keys the agent holds, with their status and dates, to verify its author signatures. |
GET /v1/public/handles/:workspace/:agent | { handle, address, card } for a public listing; a renamed handle adds redirected_from. |
#Metrics
| Method & path | Notes |
|---|---|
GET /v1/deliverability | Your bounce and complaint rates over 7 and 30 days with the thresholds at which sending pauses, suppressed contacts, your domains’ SPF/DKIM/DMARC status and our shared IP’s blocklist status. Needs metrics:read; workspace keys only. See Deliverability. |
GET /v1/metrics | ?from=&to=&tz=&inbox_id=&bucket=hour|day, at most 90 days; tz is a zone name such as Europe/Paris (default UTC). Sent, received, delivered, bounced, complained and blocked per bucket, plus deliverability rates, inbound spam counts and resource totals (up to a minute old). queue lists every inbox that uses the claim/ack queue with depth (waiting), in_flight (live leases), dead_letters and oldest_waiting_at. Needs metrics:read. At most 30 calls a minute per workspace, across all its keys: 429 rate_limited with Retry-After. |
#Domains
| Method & path | Notes |
|---|---|
POST /v1/domains | { domain, receiving? } → 201 with status: "pending" and the DNS records to publish. How many depends on the plan (Free 1, Builder 10, Team 50; 403 plan_limit_domains past it). See Custom domains. |
GET /v1/domains | List your domains. |
GET /v1/domains/:id | One domain with its records and each record’s last check result. dns says where the domain is hosted (zone, nameservers, provider, from the NS records; null until found); each record then has host, the part to type at providers that add the zone, and hint: "full_name_in_host" when it is missing but published under the doubled name. See Custom domains. |
GET /v1/domains/:id/zone-file | The records to publish as a BIND zone file, { filename, content }, for DNS providers that import one. See Custom domains. |
POST /v1/domains/:id/verify | Checks the DNS records now and returns the domain. At most once every 10 seconds per domain. |
POST /v1/domains/:id/dkim/rotate | Starts a DKIM key rotation: a new key on the other selector (cm1 ⇄ cm2), listed in records as dkim_next. Signing switches as soon as its record is seen. See Rotating the DKIM key. |
POST /v1/domains/:id/dkim/activate | Checks DNS now and switches signing to the new key; 409 dkim_record_not_found until the record is published. |
PATCH /v1/domains/:id | { receiving } turns receiving on or off. |
DELETE /v1/domains/:id | 409 domain_in_use while inboxes use the domain. ?force=true deletes those inboxes too, the same way as deleting each one. |
#Agent identity
Sign in with Agentboxd: an OpenID Connect provider where an agent’s identity is its inbox. Apps are registered per workspace (inbox-scoped keys get 403).
| Method & path | Notes |
|---|---|
POST /v1/inboxes/:id/identity-token | { audience, nonce?, scope?, expires_in? } → 201 { id_token, token_type, issuer, audience, sub, jti, scope, expires_in, expires_at }. A single-use ID token for one app, at most 5 minutes. :id is an inbox or an identity without a mailbox. Needs identity:sign. Errors: 400 unknown_audience, 400 invalid_scope, 403 identity_disabled, 403 identity_unavailable (temporary inbox), 429 rate_limited (30 per inbox per minute). |
POST /v1/identities | { username?, display_name?, client_id?, metadata?, card? } → 201 an identity without a mailbox (200 on a client_id replay). Needs inboxes:write and a workspace key. 403 plan_limit_identities over the plan’s identities; 409 address_taken, 409 client_id_conflict. |
GET /v1/identities | Identities without a mailbox, newest first, paginated; metadata.<key>= filters. Needs inboxes:read. |
GET /v1/identities/:id | { id, kind: "identity", address, username, display_name, client_id, metadata, identity_enabled, status, paused_at, paused_reason, created_at }. Inbox ids answer 404 here. |
PATCH /v1/identities/:id | { display_name?, metadata? }. Needs inboxes:write. |
DELETE /v1/identities/:id | Removes the identity: it can’t sign in any more and its handle is never reissued. Needs inboxes:write. |
GET /v1/identity/clients | Your registered apps. Needs identity:manage. |
POST /v1/identity/clients | { name, type: "confidential" | "public" | "verify_only", redirect_uris?, allowed_scopes?, subject_type?: "pairwise" | "public", homepage_url? } → 201 { client, client_secret } (the secret only for confidential apps, shown once). |
GET /v1/identity/clients/:id | One app: client_id, type, redirect_uris, allowed_scopes, subject_type, secret_prefix, enabled, last_used_at. |
PATCH /v1/identity/clients/:id | { name?, redirect_uris?, allowed_scopes?, homepage_url?, enabled? }. type and subject_type are fixed. |
DELETE /v1/identity/clients/:id | Removes the app. Its tokens stop working at the token and userinfo endpoints at once. |
POST /v1/identity/clients/:id/secret | New client secret → { client, client_secret }. The old one stops working. |
GET /v1/inboxes/:id/identity | { inbox_id, enabled, apps }: whether the inbox can sign in and the apps it signed in to. Needs inboxes:read. |
PATCH /v1/inboxes/:id/identity | { enabled } turns the inbox’s identity on or off. Needs identity:manage. |
GET /v1/inboxes/:id/identity/sign-ins | Sign-in log, newest first, paginated. Needs inboxes:read. |
The issuer (https://id.agentboxd.com) serves the OpenID Connect endpoints: {issuer}/.well-known/openid-configuration (also {issuer}/.well-known/oauth-authorization-server), {issuer}/.well-known/jwks.json, {issuer}/authorize, {issuer}/token (grants authorization_code and urn:ietf:params:oauth:grant-type:jwt-bearer, plus refresh_token for MCP connector clients), {issuer}/userinfo, {issuer}/revoke, {issuer}/introspect (RFC 7662) and {issuer}/logout (end session). See Sign in with Agentboxd. For the hosted MCP connector it also takes MCP clients: {issuer}/register (open dynamic client registration, RFC 7591) and client IDs that are metadata-document URLs.
Agent message signatures use separate keys, published with their status (active, retired, revoked with revoked_at) at {issuer}/.well-known/agent-keys.json. They are never removed, so a stored signature keeps verifying.
#Agent self-signup
An agent with no key can create its own workspace: see Agent self-signup. The first two routes need no key and are rate limited per IP address and network.
| Method & path | Notes |
|---|---|
GET /v1/signup/challenge | No key. { challenge, algorithm: "sha256", difficulty, expires_at, instructions, terms, terms_url }. Find a decimal solution so SHA-256(challenge + : + solution) starts with difficulty zero bits. A challenge is valid 10 minutes and works once. |
POST /v1/signup | No key. { challenge, solution, agent_name?, owner_email? } → 201 { api_key, workspace, inbox, claim, restrictions, docs_url, next_steps }. Creates an unclaimed Free workspace with one inbox and an inbox-scoped key (Send & read plus webhooks:manage), shown once. agent_name is one line of plain text. More agents for the same owner go in the same workspace with POST /v1/signup/agents. With owner_email we email that person a claim link. Errors: 400 invalid_solution, 400 challenge_expired, 400 challenge_used, 422 invalid_owner_email, 429 signup_rate_limited, 429 owner_limit_reached, 429 unclaimed_limit_reached, 503 signup_disabled. |
POST /v1/signup/agents | { kind?: "mailbox" | "identity", expires_at? } → 201 { api_key, api_key_expires_at, kind, workspace, inbox, identity, next_steps }. With expires_at (ISO 8601, at most 1,830 days away) the new key stops working then (401 api_key_expired). Another agent in the same unclaimed workspace, with its own key scoped to the new inbox (or identity). Use it instead of signing up again for the same owner: one workspace holds up to 3 agents until a person claims it (403 plan_limit_inboxes / 403 plan_limit_identities after that). Any key of the workspace (inboxes:read). 409 not_unclaimed for a workspace with a human owner: create inboxes with POST /v1/inboxes and a workspace key. |
POST /v1/signup/claim | { email } → 202 { status: "email_sent", email, expires_at }. Emails a single-use claim link (72 hours) to a person. At most 3 a day. Needs messages:send. 409 already_claimed or 409 not_claimable for a workspace that already has an owner; 429 owner_limit_reached when that owner already has as many unclaimed workspaces as allowed. 409 claim_email_locked when an operator merged several agent workspaces of one owner into this one: the link can only go to that owner. |
GET /v1/account | { workspace, claim: { status: "unclaimed" | "claimed" | "not_applicable", claimed_at, pending_email, expires_at }, limits, restrictions }. restrictions (unclaimed only) includes recipients_per_day and recipients_today. agent_messaging: { enabled, directory_enabled, agent_keys_enabled, public_directory_enabled } says whether agent messaging, the agent directory, agent-held keys and the public directory are on. workspace.notice ({ message, created_at } or null) is a message from Agentboxd to this workspace: while one is set, every API response to the workspace also carries it in the Agentboxd-Notice header (percent-encoded). An agent should show it to its human. Any key with inboxes:read. |
#The message object
Every message has text, html, extracted_text, headers, labels, status (queued | sent | delivered | bounced | complained | failed | received), attachment metadata, contact_id (inbound: the sender’s contact; outbound: the first recipient’s; or null), and ai.verification ({ code, link, confidence, jev_probability, auth_failed } or null; auth_failed is true when the sender failed authentication, so the code may be spoofed). Inbound messages also get the JEV fields described below once they have been categorised, and analysis, which says whether that check ran (null on sent mail). Agent messaging adds channel (agent, email, mixed), type (message, task, event), data (structured JSON or null), delivery (sent messages: where each copy went) and agent (the verified sender and signature of an agent copy, else null). Received messages carry auto_reply: true for an automatic reply (out of office, vacation responder, “we got your email”), from its Auto-Submitted: auto-replied, X-Autoreply, X-Autorespond or Precedence: auto_reply header, or the ai:auto-reply label. Don’t let an agent answer those: two automatic senders can reply to each other forever. message.received still fires for them, with the same field, so skip them in your handler. The headers count at once; the label comes with categorisation, so a responder that sets no header is flagged in message.enriched. Messages sent from a draft carry approval (who approved it, when, and the hash of the content it covered). A message that carries a meeting has calendar ({ event_id, method }), others calendar: null. author is the sending agent’s own author signature ({ verified, kid, alg, signature, reason? }) on sent messages and agent copies that carry one, else null.
A sent message is queued until it has left our server. When a receiving server answers “try again later” (greylisting, a rate limit, a full mailbox), we keep trying for about 48 hours, at growing intervals: 1, 5, 15 and 30 minutes, 1 and 2 hours, then every 4 hours. Meanwhile it stays queued, and headers["x-mailroom-deferred"] holds the server’s last answer. As soon as one recipient has it, the status is sent and message.sent fires; the others keep being tried, and nobody gets it twice. A permanent refusal is final at once (bounced, message.bounced). If the last try fails, the status is failed, or stays sent when some recipients got it, with the reason in headers["x-mailroom-error"].
body_truncated is true when text, html and extracted_text were cut. A response that lists messages (the message lists, search, a thread, a claim, batch-get) holds at most 3 million characters of bodies: each message gets an equal share, at most 100,000 characters a field (10,000 in a page of 100). GET /v1/messages/:id always has the whole body, with body_truncated: false.
extracted_text is the new part of an inbound message, without quoted history or signatures. To find it we read the top of the message. If the quoted history doesn’t start near the top of a long message, for example when the answers sit between quoted lines, extracted_text holds the full text.
Every message also has ai_disclosure: for sent mail, the Agent-Disclosure header we added ({ agent, on_behalf_of, operator, verified: true, header }); for received mail, the sender’s header parsed, untrusted unless verified is true; null without one. See AI disclosure.
Labels you may see on inbound mail: spf-fail and dmarc-fail (authentication failed), the ai:* labels below, bounce-report on delivery-failure notices, and virus-unscanned on mail accepted while the virus scanner was briefly unavailable (infected mail is refused, never delivered). Its files were never scanned: don’t open or forward them until a person has checked them. No API call can add or remove virus-unscanned, and the MCP server warns about the files of such a message.
#Categories and risk flags
Shortly after an inbound message is stored, JEV reads the sender, subject, the first 3,000 characters of the text, up to 1,000 more characters from further down a longer text (where the local check saw instructions, else the end), up to 1,000 characters of text hidden in the HTML (marked as hidden), our own SPF/DKIM/DMARC results, the attachment names, the first 1,000 characters of each extracted attachment and, for agent messages, the first 1,000 characters of their structured data, and answers six questions in one call. The injection score covers that data too. The results are merged into message.ai and a message.enriched webhook fires. message.received is never delayed by this. Bounce reports are not categorised.
{
"verification": null,
"category": { "label": "billing", "confidence": 0.94, "probabilities": { "billing": 0.94, "other": 0.06 } },
"risk": { "injection": 0.01, "phishing": 0.03 },
"needs_human": 0.22,
"urgency": { "level": "normal", "score": 1.4, "confidence": 0.72 },
"auto_reply": 0.03,
"model": "jev",
"enriched_at": "2026-09-25T12:00:04.000Z"
}| Field | Meaning |
|---|---|
category | One of support, sales, billing, verification, notification, newsletter, personal, other, with its confidence and the probability of each option. |
risk.injection | Probability that the message tries to instruct an AI (ignore its rules, reveal data, act for the sender). |
risk.phishing | Probability of credential harvesting, a scam or impersonation. The authentication results are part of the input. |
needs_human | Probability that a person should handle it: legal matters, complaints, threats, sensitive personal data. |
urgency | level is the most likely of low, normal, high, critical; score is the expected value from 0 to 3. |
auto_reply | Probability of an out-of-office, auto-responder, ticket acknowledgement or bulk mail. |
enrichment_error | Set instead of the fields above when enrichment gave up after its retries. The message is otherwise unaffected. |
All probabilities are 0 to 1. A label is only added when a score passes its threshold, so a message without ai:phishing can still have a non-zero risk.phishing. Read the scores if you need a different cut-off.
Was it checked?
A message without an ai:injection-risk or ai:phishing label is not always a checked message. It may not be checked yet, the check may have been skipped or failed, or it may have read only the start of a long message. analysis on every received message says which.
{ "state": "completed", "coverage": "partial", "reason": null }| `state` | Meaning |
|---|---|
pending | No result yet. The check usually finishes within a minute of arrival; mail in a paused inbox waits until it is resumed. |
completed | Checked. coverage is full when JEV read the whole message, partial when it read only part: the text or the structured data was longer than its excerpt, or an attachment’s text was cut or couldn’t be read. null on messages checked before this was recorded. |
skipped | Not checked. reason: quota (the plan’s email or AI quota is used up), ai_off (AI processing is off for the workspace), not_available (no check on this server), blocked (stopped by your lists) or not_applicable (delivery reports and messages from Agentboxd). |
failed | The check gave up after its retries, or couldn’t be started (ai.enrichment_error says why). |
partial is common for business mail: a logo or signature image with no extracted text is enough. It is expected, not alarming: it says the check didn’t read everything. coverage is about the new text of the message (extracted_text); quoted history below it isn’t read and doesn’t make it partial, so treat quoted text as unchecked.
Events carry the state when they were sent. message.received says skipped straight away when the check won’t run, and pending otherwise; message.enriched follows with completed. A check that fails later sends no event, so read the message again (GET /v1/messages/:id) when it matters.
Only completed with coverage: "full" means the whole message was read and no warning was raised. For sensitive work, claim with analysis: "completed" (claim/ack queue) and treat partial as unchecked beyond the excerpt. The MCP server says the same next to its warnings (ai_check). The scores are a model’s judgement: they lower the risk, they don’t remove it.
| Label | Added when |
|---|---|
ai:<category>, e.g. ai:billing | Category confidence is 0.6 or more. |
ai:uncertain | Category confidence is under 0.6 (instead of a category label). |
ai:injection-risk | risk.injection is 0.8 or more. |
ai:phishing | risk.phishing is 0.8 or more. |
ai:needs-human | needs_human is 0.7 or more. |
ai:auto-reply | auto_reply is 0.8 or more. |
ai:urgent | urgency.level is high or critical. |
local:injection-risk | The local check scored 0.6 or more (no AI, see below). |
local:spam | Bulk marketing: list headers and marketing wording, not a reply (no AI, see below). |
Labels are only ever added; your own labels are never removed or reordered. Filter on them with GET /v1/inboxes/:id/messages?labels=ai:billing,ai:urgent (a message must have every label you list).
Local checks (no AI)
Two checks run on our own server on every received message, with AI processing on or off. Nothing leaves the server for them. Their labels start with local:, so they are never mistaken for an AI verdict.
`local:injection-risk` reads the whole message, which the AI check doesn’t: the text, the HTML, structured data and attachment text (calendar invites included). Each attachment’s text is checked as soon as it is read, before GET …/text returns it and before attachment.extracted fires. A warning found there holds the message first and sends the phishing escalation. It looks for text aimed at an AI (“ignore previous instructions”, asking to forward all mail or reveal a system prompt, “don’t tell the user”, tool-call text, in English and French) and for text hidden from a person reading the mail: display:none, invisible or tiny fonts, text in the colour of its background, off-screen text, HTML comments, invisible characters. The result is in ai.local_screen: a score from 0 to 1, flagged at 0.6 or more, and reasons, each with a code, where (visible, hidden, beyond_window for text past what the AI check reads, attachment) and a short excerpt (left out of held and unscreened mail an API key reads). Hidden text alone never flags a message (newsletters hide their preview text); an instruction to an AI is always needed. The MCP server, the CLI and the dashboard warn on it like on ai:injection-risk.
"local_screen": {
"score": 1,
"flagged": true,
"reasons": [
{ "code": "instruction_override", "weight": 0.6, "where": "hidden", "detail": "ignore your previous instructions" },
{ "code": "exfiltration_request", "weight": 0.45, "where": "hidden", "detail": "forward the last 20 emails" },
{ "code": "hidden_instructions", "weight": 0.3, "where": "hidden" }
],
"hidden_chars": 209,
"complete": true,
"version": 1
}`local:spam` marks bulk marketing: mail with list headers (List-Unsubscribe, List-Id, Precedence: bulk) and clear marketing wording, that is not a sign-in code and not a reply in a conversation the inbox already has. ai.local_spam.reasons says why.
Promotions & spam view. Mail labelled ai:sales or local:spam can be shown apart in the dashboard. Nothing is deleted, and the API still returns it: GET /v1/inboxes/:id/messages?promotions=exclude leaves it out, promotions=only lists just that. Each inbox has promotions_filter, on by default for role addresses (hello@, info@, contact@, support@, help@, team@, security@, abuse@ and similar) and off for others; change it with PATCH /v1/inboxes/:id. At hello@, hi@, info@, contact@, team@ and office@, where quote requests arrive, only local:spam goes to the view, not ai:sales.
#Screening
Screening keeps suspicious and unchecked mail away from your agents. In a workspace that screens agent mail, an API key (and the MCP server) never reads the content of held or unscreened mail unless the request opts in, whatever the endpoint. Every received message has a screening block that says where it stands.
{ "state": "held", "reason": "check_failed", "released_at": null }| `state` | Meaning |
|---|---|
screened | The injection and phishing check completed without a warning. Also, with a reason, when nothing is checked by configuration: ai_off (AI processing is off for the workspace), not_available (no check on this server), and not_applicable (a delivery report about a message this inbox sent, and messages from Agentboxd). With AI processing off, agents get mail that nobody checked: that is the choice the workspace made, and the reason says so. |
pending | The check hasn’t finished yet, usually under a minute. |
held | Kept from agents. reason: injection_risk or phishing (a warning), check_failed (the check failed, or didn’t finish within 15 minutes), quota (the plan’s email or AI quota is used up), check_timed_out (still not checked after 15 minutes; recorded as check_failed within 5 more), partially_screened (part of it couldn’t be checked: text past the scan limit, more than 20 attachments with text, an attachment over 10 MB (not stored) or over the 25 MB reading limit, an attachment read only in part, an email forwarded as an attachment, or an archive we don’t unpack; labelled local:not-fully-screened, with the parts in ai.unscreened_parts. Small zip and gzip files of text, such as DMARC reports, are unpacked and checked like other attachments. A delivery report about a message this inbox sent stays screened even though it quotes that message) or blocked (stopped by your lists). A check that completes later turns it screened (or held for a warning). |
released | A person released it to agents (released_at). Agents get it like screened mail. |
What an API key gets
- Claim and wait.
POST …/messages/claim,GET …/messages/waitandGET …/verificationonly return screened and released mail (a code or link from held mail never comes back). - Reading.
GET /v1/messages/:id, the message list,batch-get, threads and contacts return held and unscreened mail with its metadata only:id,from,to, dates,labels,analysis,screening, andwithheld: { state, reason, attachments }. The subject reads[held: <reason>]or[not screened yet], because a subject can carry an injection too;text,html,extracted_text,headers,attachments,data,ai.verificationand the excerpts inai.local_screen.reasons(detail) are left out. A thread whose first message is withheld shows a placeholder subject. Search doesn’t match withheld mail. - Files and actions. Raw MIME, attachment downloads, attachment text and extraction, forwarding and reply drafts of withheld mail answer
409 message_withheld. Reply drafts also leave withheld messages out of the thread they read, for every caller. - Opting in. Add
include_unscreened=true(query, orinclude_unscreened: truein a JSON body) to read mail whose check hasn’t finished. Addinclude_held=trueto read held mail: it needs themessages:releasepermission (403 insufficient_permissionsotherwise). Both together return everything, as before screening. - Events.
message.receivedfires on arrival, usually before the check finishes, so in a screening workspace it carries no content then (the same metadata-only message, subject[withheld from this event: read the message]). When no check will run (no model on the server, or AI processing off), the message is already screened with that reason and the event carries it.message.enrichedcarries the content when the result is screened; held mail arrives with its content only inmessage.released. Webhooks and the stream get the same payloads. To act on new mail, subscribe to all three events, skip an event whose message haswithheld(or, with envelope payloads, whose message still reads back with it), and handle each message id once: with AI processing off nomessage.enrichedfires, and in a workspace that doesn’t screen, each of them carries the content. Or use the claim queue. - People. Dashboard sessions read everything, so the person reviewing held mail sees it in full.
Held mail
- Seeing it.
held_countonGET /v1/inboxesandGET /v1/inboxes/:idcounts held mail nobody has opened yet; list it with?screening=held. The dashboard shows a badge per inbox, a notice on the Overview and a Held filter. The people on call get theheldescalation once per message when a check fails, is skipped over the quota or doesn’t finish (mail held for a warning escalates asphishing). - Over the AI quota. Mail that arrives over the plan’s AI quota isn’t checked, so it is held with
reason: "quota"until the quota resets or the plan is upgraded; the dashboard says so on each message. - Releasing it. A person releases a held message in the dashboard (Release to agents), or a key whose owner explicitly gave it
messages:releasecallsPOST /v1/messages/:id/release. That permission is in no preset, not even Full access. Each release emitsmessage.released, the audit record (who, when, why it was held). The MCP server has no release tool: an agent never decides that mail held from it is safe. - Labels can’t release it. The labels screening reads are reserved:
ai:*(exceptai:needs-human),local:*,blocked,bounce-report,complaint-report,unscreened,over_quota,spf-fail,dmarc-failand the welcome label. Adding or removing them answers422 reserved_label. So doesvirus-unscanned, the only record that a message’s files were never scanned for viruses. - The `unscreened` label. Mail whose check failed or was skipped over the quota is labelled
unscreened; the label goes when a later check completes. Filter on it with?labels=unscreened. - Bounce reports. A delivery report counts as one (label
bounce-report,not_applicable) only when it reports on a message the inbox sent. Anyone can send amultipart/report, so any other one is screened and announced like ordinary mail. - Complaint reports. A spam-complaint report from a mail provider counts as one (label
complaint-report, never announced) only when the provider signed it and it reports on a message the inbox sent. Anyone can send a report, so the local checks run on every one, and a warning holds it like other mail. Any other one also goes through your receive lists and is announced like ordinary mail.
Turning it on
New workspaces screen agent mail. Workspaces that existed before screening shipped (October 2026) keep reading every message, as before, until an owner turns on Agents get only screened mail in Settings (screened_only on PATCH /platform/org). An API key can’t change it. With it off, nothing on this page applies except the screening block and the reserved labels.
#Limits and errors
| Status & code | When |
|---|---|
401 unauthorized | Missing or revoked API key. |
401 api_key_expired | The API key is past its expires_at (details.expired_at). Create a new key and revoke this one. |
403 forbidden | The action needs a workspace owner, for example changing AI processing. |
403 ai_disabled | A reply draft or a structured extraction was requested but AI processing is not full. |
403 sending_paused | Sending is paused for the workspace (hard-bounce rate over 5% or complaint rate over 0.1% in the last 7 days, or an operator). Receiving keeps working and queued mail waits. Clean the recipient list, then press Request review on the dashboard banner. See Sending health. |
403 org_suspended | The workspace is suspended (clear abuse, a second pause within 30 days, or an operator). Queued mail is not sent. Request a review on the dashboard banner, or write to support@agentboxd.com. |
403 temporary_inbox_receive_only | Send or reply from a temporary inbox. They only receive. |
423 inbox_paused | The inbox is paused. Resume it with POST /v1/inboxes/:id/resume (or in the dashboard) to send again. |
423 workspace_stopped | The workspace is under an emergency stop: no sends, identity tokens or scheduled sends until an owner resumes it in the dashboard. Don’t retry. |
403 domain_not_verified | Send from an inbox whose domain failed its DNS checks. Fix the records and check the domain again. |
409 domain_taken · 409 domain_in_use | The domain is verified by another workspace; or you tried to delete a domain that inboxes still use. |
413 | Message or attachment over the size limit. |
422 attachment_infected | An attachment contains malware (the Customs virus scan); the message names the file and the signature. Nothing was sent. |
503 scanner_unavailable | The virus scanner could not check the attachments. Retry shortly. |
422 recipient_suppressed | The address hard-bounced or complained after mail from your workspace. |
422 invalid_webhook_url | The webhook URL isn’t a public HTTPS endpoint: it uses http://, contains a username or password, uses a port other than 443 or 1024–65535, or points at a private or reserved address or an internal name. See Webhooks. |
402 plan_limit_emails | The plan’s monthly emails are used up. Receiving keeps working. See Plans and limits for every plan error. |
403 plan_limit_inboxes | The plan’s inboxes are all in use. |
429 daily_send_limit_exceeded | This inbox’s daily send limit reached. Resets at 00:00 UTC. |
429 org_daily_limit_exceeded | The workspace’s daily send cap (from its plan) reached. Resets at 00:00 UTC. A new workspace’s cap is 20 for its first 3 days, on any plan (new_workspace_limits_until on the org). |
429 rate_limited | Too many requests for this API key in a minute; or, on send, reply and draft send, the workspace’s burst limit (sends per 5 minutes, from its plan). The burst error has details: { limit: "sends_per_5min", max, window_seconds, retry_after_seconds } and a Retry-After header. Wait that long, then retry. |
400 draft_incomplete | A draft was sent without a recipient or any text (details.missing). |
400 invalid_send_at | send_at is less than 1 minute or more than 30 days away. |
422 attachment_missing | A file on the draft is no longer stored, so the draft can’t be sent (details.attachment_id). Attach the file again. A scheduled draft ends failed. |
409 draft_not_editable · 409 draft_already_sent | The draft’s status doesn’t allow that change (details.status), or it was already sent (details.sent_message_id). |
409 draft_changed | The draft changed since the version you named in expected_content_sha256 (or someone scheduled or unscheduled it while you sent it). Nothing happened. Read the draft again and review it. |
429 temporary_inbox_limit | 25 temporary inboxes are already active in the workspace. Creating them is also limited to 60 an hour. |
503 llm_unavailable | The drafting model didn’t answer. Nothing was sent; try again later. |
429 unclaimed_recipient_limit | An agent-created workspace that no one has claimed yet sent to its 20 different recipients for the day. Replies to people who emailed the agent in a thread they started still work. A claim lifts it; see Agent self-signup. |
403 unclaimed_workspace | Webhooks, custom domains, temporary inboxes and identity tokens need a claimed workspace. |
429 signup_rate_limited · 429 unclaimed_limit_reached · 503 signup_disabled | Too many self-signups from one IP address or network, too many unclaimed workspaces from the last 7 days held by one, or self-signup is off on this server. |
400 too_many_labels | A label change would leave a message, thread or contact with more than 100 labels (details.max). Labels Agentboxd sets count too. Nothing was changed: remove some labels first. Removing labels always works. |
409 lease_expired | An ack, nack or extend presented a lease_id that is no longer the message’s current lease: it ran out and the message was claimed again, or it was released or dead-lettered. The work may be done twice; see Claim and ack. |
409 extraction_pending · 409 extraction_unavailable | Structured extraction on an attachment whose text isn’t ready yet, or has no text (see Document extraction). |
400 invalid_schema · 422 structured_output_invalid | A custom extraction schema was refused; or the model’s answer didn’t match the schema after one retry (details.errors). |
402 plan_limit_structured_extractions | The plan’s structured extractions for this period are used up (Free includes none). |
403 identity_disabled · 400 unknown_audience | An identity token was asked for an inbox whose sign-in is off, or for an app (audience) that doesn’t exist or is disabled. |
422 identity_only | Send, reply or drafts on an identity without a mailbox: it has no mail. The claim queue too, unless agent messaging is on (identities then receive native agent messages). |
422 invalid_agent_signature | The agent_signature on a send or reply doesn’t verify: details.reason is malformed, wrong_type, unknown_key, key_retired, key_revoked, bad_signature, wrong_sender, stale (older than 5 minutes), replayed or content_mismatch (details.field). See Author signatures. |
400 invalid_key · 422 invalid_proof · 409 key_exists · 422 too_many_keys | Registering an agent key: not a public Ed25519/P-256 key, the proof of possession failed (details.reason), the key was registered before, or the agent already has 3 active keys or 20 keys in all. |
503 agent_keys_disabled · 503 public_directory_disabled | Agent-held keys, or the public directory and handles, are not on for this server yet. |
400 invalid_handle · 409 handle_taken · 409 handle_reserved · 422 workspace_handle_required · 422 handle_not_found | Handles: wrong syntax; taken or used before (handles are never reused); reserved (details.reason: reserved or domain); an agent handle before the workspace has one; a recipient handle nobody answers to (details.handles). |
#Dashboard and account API
The dashboard at agentboxd.com uses these routes with a session cookie (cm_session) instead of an API key. Requests that change data (POST, PATCH, DELETE) must send X-Requested-With: agentboxd-web, or they get 403 csrf. You don’t need them to build an agent; they are listed so nothing the dashboard does is hidden.
| Method & path | Notes |
|---|---|
POST /platform/auth/magic-link | { email }. 204 whether or not an account exists. Emails a sign-in link from login@agentboxd.com, valid 15 minutes, usable once. At most 3 links an hour to one address and 20 an hour from one IP: over that, 429 rate_limited with Retry-After. |
POST /platform/auth/verify | { token } from the link. 200 { user, org } and sets the session cookie; 400 invalid_token if the link expired or was used. The link opens a page with a Continue button, and only the button calls this: opening the link signs nobody in, so a mail scanner that opens it doesn’t use it up. |
GET /platform/auth/github | Starts GitHub sign-in (?next=/app). Not enabled yet: it redirects to /login?error=github_unavailable. |
POST /platform/auth/logout | 204. Ends the session. |
POST /platform/auth/logout-all | 204. Ends every session of your account, on every device, this one included. A session also ends 90 days after its sign-in, however often it is used. |
GET /platform/account/deletion | { workspaces: [{ id, name, role, members, outcome }], blocked }: what deleting your account would do to each workspace you belong to. delete: you are its only member, so it is deleted with your account. leave: you leave it and it keeps working. blocked: you are its last owner and others use it (blocked_by: "last_owner"), or it would be deleted but has a paid subscription that still renews ("subscription") or runs Inbox Heist ("game_workspace"). |
POST /platform/account/delete | { confirm_email } (your address, typed) → { deleted_workspaces, left_workspaces, sessions }. Deletes your account: you leave shared workspaces (member.removed, reason account_deleted; the API keys you created stay with them) and every workspace you are the only member of is deleted: its API keys are revoked, its inboxes, mail, files, webhooks, domains, contacts and settings are removed, and so are its requests for higher limits or a review (with your address and what you wrote); inbox addresses stay reserved. Every session ends, your mail app (IMAP) passwords stop working and a confirmation email goes to your address. 400 confirmation_mismatch; 409 last_owner (with details.workspaces) when you are the last owner of a workspace others use: make someone else an owner first; 409 subscription_active when a workspace that would be deleted has a paid subscription that still renews: cancel it under Manage billing first; 409 game_workspace when a workspace that would be deleted runs Inbox Heist: invite someone to it and make them an owner first. You can sign up again with the same address later, as a new account. |
GET /platform/me | { user, org, role, workspaces }, or 401 unauthorized. role is yours in this workspace (owner or member); workspaces lists every workspace you belong to as { id, name, role }, oldest first. The org includes status, daily_send_limit, new_workspace_limits_until, ai_processing, retention_days, identity_share_workspace, local_processing, ai_disclosure, emergency_stopped_at, emergency_stop_reason, and sending_paused_at and sending_paused_reason while sending is paused. |
POST /platform/session/workspace | { org_id } → { user, org, role, workspaces }. Moves this session to another workspace you belong to (the account menu’s switcher); 404 if you are not a member. Sign-in opens the workspace you joined first. |
GET /platform/members | Everyone in the workspace, oldest first: { data: [{ user_id, email, name, role, joined_at, api_keys }] }. api_keys counts the live keys the person created; they stay with the workspace if the person leaves. Every member can read it. |
PATCH /platform/members/:userId | { role: "owner" | "member" } → { member }. Owners only (403 forbidden). A workspace keeps at least one owner: demoting the last one is 409 last_owner. Making an owner a member also revokes the invites they sent that were not accepted yet. Emits member.role_changed, and member.invite_revoked for each invite revoked. |
DELETE /platform/members/:userId | 204. An owner removes someone, or you leave (your own id). Their sessions in this workspace end at once (their event streams of it within two minutes), the mail app (IMAP) passwords they created there stop working and the invites they sent that were not accepted yet are revoked; the API keys they created stay, and the keys list still shows who created them. Drafts they scheduled fail at their send time with approver_gone. The last owner can’t leave or be removed (409 last_owner). Emits member.removed, and member.invite_revoked for each invite revoked. |
GET /platform/invites | Invites not yet accepted or revoked, newest first: { data: [{ id, email, role, status: "pending" | "expired", invited_by, created_at, sent_at, expires_at }] }. Every member can read it. |
POST /platform/invites | { email, role?: "member" | "owner" } → 201 { invite }. Owners only, so only owners invite owners. Emails a link from login@agentboxd.com, valid 7 days and usable once; only a hash of it is stored. Inviting an address again gives it a new link and 7 more days. Members plus pending invites count as seats: over the plan’s seats, 403 plan_limit_seats. 409 already_member; 403 unclaimed_workspace for an agent-created workspace no person claimed; 403 org_suspended while the workspace is suspended; at most 20 invite emails a day per workspace and 3 per address (429 rate_limited). Emits member.invited. |
POST /platform/invites/:id/resend | { invite }. Owners only. A new link (the old one stops working) and 7 more days; counts toward the same daily limits, and is refused the same way while the workspace is suspended. Emits member.invited with resent: true. |
DELETE /platform/invites/:id | 204. Owners only. The link stops working and the seat is free again. Emits member.invite_revoked. |
POST /platform/invites/preview | { token } from an invite link → { workspace: { name }, role, email, invited_by, expires_at, signed_in_as, email_matches }. Public; consumes nothing. 400 invalid_token if the link expired, was used or revoked, or the person who sent it is no longer an owner of the workspace. |
POST /platform/invites/accept | { token } → 200 { user, org, role } and the session cookie. Public but always needs X-Requested-With: agentboxd-web. The link proves you own the address, like a sign-in link: you are signed in to the workspace, and a new address gets an account (with no workspace of its own). With a session for another address: 403 invite_email_mismatch. The seat is checked again (403 plan_limit_seats). 400 invalid_token for the same reasons as the preview. The invite page calls it only when you press Accept. Emits member.joined. |
GET /platform/api-keys | API keys: name, prefix, created, last used, revoked, expires_at (null = never), created_by, scope, inbox_id, inbox_address, permissions and the matching preset (or custom). Never the secret. created_by is { email, member }: the person who created the key, and member: false once they are no longer in the workspace (their keys keep working until revoked). email is null for a deleted account; created_by is null for a key no person created, such as the one made at sign-up. |
POST /platform/api-keys | { name?, scope?: "org" | "inbox", inbox_id?, preset? | permissions?, expires_at? }. expires_at is an ISO 8601 time with its offset, in the future and at most 1,830 days away; null or left out, the key never expires. A draft the key schedules for after expires_at fails then with approver_gone. Without a name the key is called something like key-7f3a; without a preset or permissions it gets full access. 201 { api_key, key }: the full key is shown this once. See API keys and permissions. |
DELETE /platform/api-keys/:id | Revokes the key. 204. Drafts it scheduled fail at their send time with approver_gone. |
GET /platform/usage | ?days=30 (up to 90). Sends and receives today, the limits, the inbox count and a daily series. |
GET /platform/messages/:id/hidden-text | What a received HTML email hides from a person (CSS, hidden attributes, comments, alt text), as plain text: { text, chars, truncated, kinds }, at most 5,000 characters. The dashboard’s “View hidden content”. Sessions only. |
GET /platform/identity/authorization-requests/:id | The consent page of a browser sign-in: { request: { id, client: { client_id, name, homepage_url, redirect_host }, scopes, expires_at }, inboxes: [{ id, address, display_name, identity_enabled }] }. 404 once the request expired (10 minutes) or was answered. |
POST /platform/identity/authorization-requests/:id | { decision: "approve", inbox_id } or { decision: "deny" } → { redirect_to }: the app’s registered redirect_uri with code, state and iss, or error=access_denied. Once per request. See Sign in with Agentboxd. For an MCP connector request (kind: "connector") the body is { decision: "approve", preset?: "standard" | "full" | "read_only", inbox_id? }; see MCP server. |
GET /platform/connectors | Connected MCP apps (the hosted connector): { data: [{ id, client_id, client_name, app_host, registration, preset, permissions, scope, inbox_id, inbox_address, created_by, created_at, last_used_at, expires_at }] }, live connections only. |
DELETE /platform/connectors/:id | Disconnects the app at once: its next MCP request gets 401 and its refresh token stops working. Drafts it scheduled fail at their send time with approver_gone. 204; 404 if it is not a live connection of this workspace. |
GET /platform/inboxes/:id/imap | Reading an inbox in a mail app (IMAP): { enabled: false } when this server has IMAP off, else { enabled: true, settings: { host, port, security, username }, passwords: [{ id, name, prefix, created_by, created_at, last_used_at, expires_at, revoked_at, status }] }. created_by is the email of the person who created it (null once their account is deleted). Never a password. Every member can read it. |
POST /platform/inboxes/:id/imap/passwords | { name?, expires_at? } → 201 { app_password, password, settings }. Owners only (403 forbidden). The password (ap_…) is in this response only; we keep a hash. At most 10 live app passwords per inbox (409 too_many_app_passwords); 404 imap_disabled while IMAP is off. |
DELETE /platform/inboxes/:id/imap/passwords/:passwordId | 204. Owners only. The password stops working at once; a mail app still connected with it is cut off within two minutes. |
POST /platform/claim/preview | { token } from a claim link → { workspace, inbox_address, email, expires_at, signed_in_as, email_matches }. Consumes nothing; 400 invalid_token if the link expired, was used or the workspace is already claimed. |
POST /platform/claim/confirm | { token } → 200 { user, org }. Makes the person the owner of the agent’s workspace and signs them in to it (the link proves they own the address). Always needs X-Requested-With: agentboxd-web. With a session for another address: 403 claim_email_mismatch. |
POST /platform/waitlist | The early-access form on aSIM. { product: "agent-network", name, email, company?, use_case } → 202 { status: "received" }. Public, no session, but always needs X-Requested-With: agentboxd-web. The entry is emailed to hello@agentboxd.com and not stored; at most 5 an hour per IP and 3 a day per address (429 rate_limited). |
POST /platform/subprocessor-updates/subscribe | The “Notify me of changes” form on Sub-processors. { email } → 202 { status: "received" } and a confirmation link from login@agentboxd.com (double opt-in). Public, no session, needs X-Requested-With: agentboxd-web. The same answer whether or not the address is already subscribed; at most 5 an hour per IP and 3 a day per address (429 rate_limited). |
POST /platform/subprocessor-updates/confirm | { token } from the confirmation link → 200 { status: "confirmed" }. Single use; expires after 7 days (400 invalid_token). |
POST /platform/subprocessor-updates/unsubscribe | { token } from the link in every notice → 200 { status: "unsubscribed" }. Works any number of times; the stored address is deleted at once. |
POST /platform/emergency-stop | { reason? } → { org }. Owners only. Stops every agent in the workspace, like POST /v1/emergency-stop; see Emergency stop. |
POST /platform/emergency-stop/resume | { org }. Owners only: the only way to lift an emergency stop. Emits workspace.resumed. |
POST /platform/forwarding/confirm | { token } from the link in the forwarding confirmation email → { confirmed: true, address, inbox, workspace }. Public (the destination may have no account) but always needs X-Requested-With: agentboxd-web. Links last 7 days and work once (400 invalid_token). |
POST /platform/escalation/confirm | { token } from the link in the on-call confirmation email → { confirmed: true, email, workspace, inbox }. Public (the contact may have no account) but always needs X-Requested-With: agentboxd-web. Links last 7 days and work once (400 invalid_token). |
GET /platform/org/review | { reviewable, review }. reviewable is sending_paused or suspended when a review can be asked for, else null; review is the latest request ({ id, state, status, what_happened, what_fixed, created_at, decided_at, decision_note }, status open, resumed or declined) or null. See Sending health. |
POST /platform/org/review | { what_happened, what_fixed } (10 to 2,000 characters each) → 201 { review }. Owners only. Asks a person at Agentboxd to lift a sending pause or a suspension. 409 nothing_to_review when sending works, 409 review_requested while one is open, 429 rate_limited after 3 in 7 days. |
PATCH /platform/org | { name?, ai_processing?, retention_days?, identity_share_workspace?, local_processing?, screened_only?, ai_disclosure? }. ai_disclosure: { visible_line?: "off" | "text" | "html", text?: string | null, share_workspace_name?: boolean } is merged with the current settings; see AI disclosure. Owners only (403 forbidden otherwise). ai_processing is off, categorize or full. retention_days is 1 to 3650 days, capped at your plan’s retention, or null for the plan default. See Retention. identity_share_workspace: true lets apps that ask for the workspace scope receive the workspace name and id. local_processing: false stops attachment text extraction and OCR on our servers for new mail (default true; see Document extraction). screened_only turns screening of agent mail on or off (on for new workspaces, off for workspaces created before it shipped). |