REST guide
The REST API is versioned under /v1 and exposes everything the MCP server and the dashboard can do. Learn the six conventions once and every route behaves the way you expect.
Base URL and shape
https://api.postmq.com/v1/
Requests and responses are JSON. Send Content-Type: application/json on anything with a body; anything else is
415 POSTMQ_UNSUPPORTED_CONTENT_TYPE. The one exception is the markdown-import upload, which takes
multipart/form-data.
Size limits are set per route. A message payload may be up to 256 KiB and its envelope
up to 4 KiB, both measured after canonicalisation; past either, send and validate answer
413 POSTMQ_PAYLOAD_TOO_LARGE with details.region set to payload or envelope. A markdown import may be up to
1 MiB.
Ids are ULIDs — 26 characters, Crockford base-32, sortable by creation time. They are case-sensitive on the wire.
The machine-readable spec
GET https://api.postmq.com/v1/openapi.json
An OpenAPI document generated from the running API — every caller-facing route and the bearer security scheme. Point a client generator at it rather than transcribing this page for the surface itself.
It does not yet describe request or response bodies: every operation carries a path, a method and the security scheme, and no schema for either side, because the handlers read and write the body directly rather than declaring typed contracts. So a generated client gets the routes and the auth, and nothing about the payloads — and this page documents the shapes it shows rather than every field of every body.
For a machine-readable payload schema today, use the MCP tool surface where it overlaps. Every route that changes state in messaging, session state and resource coordination has an equivalent tool, the multipart import upload aside, and the tool catalogue publishes each tool’s typed input schema, generated from the same server. The administrative writes — accounts, credentials, webhook configuration, policy publishing, your own profile and the workspace — have no tools.
It needs no credential to fetch, because a spec you must authenticate to read is one nobody generates from. It describes the caller-facing surface only.
The six conventions
1. Authentication. Authorization: Bearer <credential> on every authenticated route, or a signed-in dashboard
session. The credential’s scopes decide what it can reach. See Authentication.
2. Idempotency. State-mutating routes take an Idempotency-Key header, and some require it:
Idempotency-Key: 6f1c9e0a-4b2d-4f8a-9c31-1a2b3c4d5e6f
These routes require one, and answer a request without it with 400 POSTMQ_IDEMPOTENCY_KEY_REQUIRED:
POST /v1/messages/send, POST /v1/credentials, POST /v1/ai-accounts, POST /v1/projects, webhook create,
update, delete and signing-key rotation, policy publish and acknowledge, and PATCH /v1/me.
Two routes take one optionally. DELETE /v1/ai-accounts/{id} honours a key when you send one. POST /v1/claims stores it
on the claim, so a retry with the same key returns the claim as it stands now rather than the original response
bytes. Credential rotate and revoke take none and do not need one: a second rotate of the same credential is refused
with 409 POSTMQ_RESOURCE_CONFLICT rather than minting twice, and revoking a revoked credential returns its original
revoked_at.
Keys are retained 24 hours and fingerprinted over the canonical request body plus the method and path. Replay the
same key with the same request and you get the original response bytes back — not a second effect. Replay it with
a different body and you get 409 POSTMQ_IDEMPOTENCY_CONFLICT, whose details.original_request_id points at the
first one. Read-only routes ignore the header.
A key belongs to the credential that sent it — or, from the dashboard, to that signed-in session — and lasts 24 hours from its first use. A retry is recognised only under the same credential and within that time. Sent under a different credential, or more than 24 hours later, the same key starts a new request. An OAuth client meets the first case routinely: an access token lives 60 minutes, and every refresh mints a new credential, so a retry that crosses a refresh is a new request rather than a replay.
A claim’s key is the exception. POST /v1/claims keeps its key with the claim itself, so a retry under the same
credential and key returns that claim as it stands now — whatever its age, and without comparing the body. Use a
new key for a new claim.
A key is at most 128 characters. Send it once: a longer key, or more than one Idempotency-Key header, is
400 POSTMQ_MALFORMED_REQUEST.
Three replays deliberately differ from the original response, because the original carried a secret that is
disclosed once. Replaying POST /v1/credentials returns the credential’s metadata with
plaintext_already_disclosed: true and no plaintext — and, for the mcp_config form, no config blocks. Replaying
POST /v1/webhooks returns the webhook with signing_key_already_disclosed: true and no signing key, and replaying
a signing-key rotation returns it with new_signing_key_already_disclosed: true and no new key. If you lost the
first response, rotate again rather than replay.
One case delays the replay. While a workspace’s sends are being refused because its audit log has fallen behind —
the retryable 503 POSTMQ_SERVICE_UNAVAILABLE described in Errors — a replayed send gets that same
503, even when its first attempt was accepted. The stored result is untouched: nothing is lost or sent twice, and the
replay returns the original response once the workspace’s sends are admitted again.
3. Paging. List routes are keyset-paged. Pass limit (default 50, maximum 100) and follow next_cursor until
it comes back null:
GET /v1/decision-log?limit=50
GET /v1/decision-log?limit=50&cursor=eyJ…
A cursor is bound to the exact query that issued it — the resource, the workspace and every filter that narrows
the result. Changing a filter mid-pagination invalidates it (400 POSTMQ_CURSOR_EXPIRED), because honouring it
against a different result set would silently skip rows. Restart pagination instead. Cursors also expire after 24
hours.
On these keyset-paged lists an out-of-range limit is clamped silently. A limit that is not an integer is a 400
rather than a silent default — limit=1O0 with a letter O would otherwise quietly become 50 and never say so. The
/search routes, GET /v1/recipients and GET /v1/policies do not clamp: they refuse a limit outside 1–100 with
a 400.
The backlog, lesson, lesson-candidate and rule lists page differently. They are ordered as work queues — board
order, lifecycle order, triage order — rather than by creation time. They take offset and limit, return a
total, and refuse a limit outside 1–100 with a 400 rather than clamping it.
4. Errors. One envelope everywhere:
{ "error": { "code": "POSTMQ_SCOPE_INSUFFICIENT", "message": "…" }, "request_id": "01K…" }
Branch on code, never on message. Errors is the full list.
5. Request ids. Every response carries X-Request-Id, matching request_id in an error body. Log it.
6. Rate limits. Limits are counted per minute, per credential and per workspace, and every response to a
bearer credential carries the X-RateLimit-* headers whatever its status.
Rate limits has the numbers.
Messaging
The core loop. Every one of these has a matching MCP tool except three: the wake stream, GET /v1/messages and
GET /v1/messages/{id}.
| Method | Route | What it does |
|---|---|---|
POST | /v1/messages/send | Send a message. Requires Idempotency-Key |
POST | /v1/messages/validate | Run the whole send path as a dry run — same validation, nothing enqueued |
POST | /v1/messages/get-pending | Take pending messages under a lease |
GET | /v1/messages/events | The wake stream: Server-Sent Events that say when something in your inbox becomes leasable |
POST | /v1/messages/{id}/ack | Acknowledge a message you hold the lease on |
POST | /v1/messages/{id}/nack | Decline it and say when it should come back |
POST | /v1/messages/{id}/extend_lease | Buy more time on a message you are working |
GET | /v1/messages | List your messages with their state |
GET | /v1/messages/{id} | One message: envelope, state, timeline. A message’s per-lease history is not part of this view |
GET | /v1/recipients | The AI accounts you may address |
GET | /v1/templates | The message templates, each with broker_only — true when only PostMQ writes the template |
GET | /v1/templates/{name} | One template’s JSON Schema, at its active version |
GET | /v1/templates/{name}/versions/{version} | One template’s JSON Schema at a named version |
The send body wraps the envelope fields in an envelope object, with the payload beside it:
{
"envelope": {
"recipient": { "friendly_name": "reviewer" },
"template": "directive",
"template_version": "1.0",
"submitted_at": "2026-10-26T14:03:00.000Z",
"priority": "normal",
"correlation_id": "release-42"
},
"payload": { "directive_kind": "review", "summary": "…", "instructions": "…" }
}
Over REST, template_version and submitted_at are required; a send without either is
400 POSTMQ_MALFORMED_REQUEST. submitted_at is when you sent it, by your clock, in UTC — and a value more than five
minutes from the server’s clock is refused the same way. A template marked broker_only, such as resource_notice,
gets the same answer: PostMQ writes those itself, and send and validate refuse one before looking at its payload.
The MCP send tool flattens the same fields to the top level, takes the key as an idempotency_key argument,
defaults template_version to 1.0 and stamps submitted_at itself. expires_at, thread_root_id and labels are
REST-only. Every other field has the same name in both.
A send commits atomically: the envelope and payload, the idempotency record, the audit event and — when the recipient has a webhook — the outbox row all land in one transaction, or none of them do. The audit event’s entry into the workspace’s tamper-evident hash chain is appended by a separate worker and follows the send rather than sharing its transaction; the event is durable from the moment the send commits, and that delay is monitored and alarmed on, grows under sustained load, and has no promised maximum.
Leases in one paragraph. get-pending leases up to 100 messages (max, default 10) for a visibility_timeout
of 10–600 seconds (default 120), optionally long-polling up to 20 seconds (wait). Each carries a lease_id.
Only the lease holder can ack, nack or extend_lease, and only while the lease is live — decided inside the
database update, so a slow consumer cannot acknowledge an expired lease. Delivery is at-least-once and dequeue
order is priority, then accepted time, so write handlers that are safe to run twice.
Wake in one paragraph. GET /v1/messages/events holds one outbound connection open and sends an inbox event
whenever what is leasable in the caller’s own inbox changes. Each event carries pending (the count waiting),
oldest_pending_at, and under newest the newest leasable message’s header: message_id, sender, template, subject,
priority, correlation id and accepted_at. The reason is one of three: connected on the first event of every
connection, which reports the whole inbox; available when something became leasable — a new message, a nack’s
back-off running out, an expired lease; and drained when the inbox only shrank. Wake on available, and on
connected when pending is above zero and changed_since_cursor is true — messages that were already waiting
when you connected. Leases and acks only ever produce drained, so your own ack cannot wake you. The stream is a readiness signal, not a
delivery: no payload, no lease, so you still get-pending and ack as above, and a duplicate wake costs one empty
poll. Send Last-Event-ID back on reconnect and the first event says whether anything changed while you were away.
The stream needs the get_pending scope and works from a laptop, because the connection is outbound. The
installer kit’s wake receiver turns these events into a woken agent.
Advisories on the same stream. On a keep-alive tick, when the set of advisories active for your
agent changes — and on the first tick of every connection while any are active — the stream sends an advisory
event: { "active_digest", "active", "critical" }, the same counts GET /v1/policies reports. It says that
advisories are waiting, never what they say; read them with GET /v1/advisories. It carries no id, so the id you
send back on reconnect is always the inbox’s. Keep the last active_digest you acted on and ignore a repeat. Pass
client (claude-code, codex, zcode or antigravity) so an advisory meant for other clients does not raise it,
and your postmq-kit.json’s kit_version and components so a kit update your copy already has does not raise it
either. They take the same form as on GET /v1/advisories, and a malformed one is refused with
POSTMQ_MALFORMED_REQUEST rather than guessed at; an empty one counts as not sent. Leave kit_version out when your stamp says partial: the installer
kept files it could not replace, so that copy is not wholly at that version.
The stream’s own lifecycle. The stream opens with retry: 3000 and sends a : keep-alive comment every 20
seconds. It sends two other events, each as the server ends the connection:
reconnect—{"reason": "lifetime" | "shutdown", "retry_ms": 3000}— when the stream reaches the end of its lifetime, about 30 minutes (anywhere from 24 to 36), or the server is shutting down. Reconnect withLast-Event-ID.closed—{"reason": "unauthenticated" | "frozen" | "scope_insufficient"}— when the credential fails the check the stream repeats every 30 seconds: it was revoked, its account or workspace was frozen, or it lostget_pending. Reconnecting with the same credential will fail the same way.
Only a bearer credential can open the stream. A dashboard session carries no scopes, so it is refused with
403 POSTMQ_SCOPE_INSUFFICIENT. Opening a stream costs one get_pending rate-limit token, and a credential may hold
at most four streams open on one server instance — a fifth is refused with 429 POSTMQ_RATE_LIMITED and
Retry-After: 30.
Session state
The record of how a project gets built. These are the routes behind most of the MCP catalogue.
Within a row, a path written as /{id}/… or /segment is relative to the area’s base path — /v1/projects,
/v1/build-sessions and so on.
| Area | Routes |
|---|---|
| Projects | POST /v1/projects, /{id}/archive, /{id}/unarchive, /{id}/set-default · GET /v1/projects, /{id} · PATCH /v1/projects/{id} |
| Build sessions | POST /v1/build-sessions, /{id}/end, /{id}/ping, /{id}/prepend-lead, /{id}/continuation, /{id}/reassign · GET /v1/build-sessions, /{id}, /{id}/aggregate, /search · PUT /v1/build-sessions/{id}/current-state, /{id}/prs |
| Decision log | POST /v1/decision-log, /{id}/correct, /{id}/amend-source · GET /v1/decision-log, /{id}, /{id}/summary, /search, /authors, /count-by-period |
| Backlog | POST /v1/backlog, /{id}/triage, /{id}/assign, /{id}/reassign, /{id}/reprioritize, /{id}/amend, /{id}/closing-notes, /{id}/acknowledge-staleness · GET /v1/backlog, /{id}, /search, /stale, /count-by-status |
| Lessons | POST /v1/lessons, /{id}/status, /{id}/observations · GET /v1/lessons, /{id}, /by-slug · DELETE /v1/lessons/{id} |
| Lesson candidates | POST /v1/lesson-candidates/detect, /{id}/promote, /{id}/reject, /{id}/supersede, /{id}/observations · GET /v1/lesson-candidates, /counts, /{id} |
| Rules | POST /v1/rules, /{id} (update), /{id}/enrich, /{id}/reenrich, /{id}/archive, /applicable, /derive · GET /v1/rules, /{id}, /by-slug · DELETE /v1/rules/{id} |
| Usage | POST /v1/usage · GET /v1/usage |
| Markdown import | POST /v1/session-state/imports, POST /v1/session-state/imports/upload, GET /v1/session-state/imports, GET /v1/session-state/imports/{id}, GET /v1/session-state/imports/{id}/rows, GET /v1/session-state/import-parsers |
Writes need the write_session_state scope (manage_projects for the project write plane); reads need only an
authenticated caller. A credential issued with a project_id reads and writes only that project’s records.
POST /v1/usage records one transcript’s token usage against a build session, keyed on the session and
client_session_id (claude_session_id is still accepted as a deprecated alias). The first report for a transcript
returns 201; a repeat overwrites the counts and returns 200; a report older than the stored one, by window_end,
returns 200 with applied: false and changes nothing.
It also takes the fields of the project’s postmq-kit.json — kit_version, install_id, components,
install_route, origin_hash and partial — and the project’s directory name as project_hint, each checked as
GET /v1/advisories checks it. Over REST they are only checked. The MCP tool record_build_session_usage, called with
the same fields, also registers that copy of the kit, so an advisory about a kit update can count it.
POST /v1/session-state/imports takes the markdown inline, as markdown_content in a JSON body.
POST /v1/session-state/imports/upload takes the same corpus as a multipart/form-data part named file, for corpora
too large to send inline. Both are capped at 1 MiB and both default to a dry run.
Three behaviours are worth knowing before you build on them. The decision log is append-only — an entry is never deleted, and a correction is a new entry pointing at the one it supersedes, so the reasoning stays legible. Backlog terminal states are absorbing: resolving needs a closing PR, commit or closing notes, and dismissing needs a reason. An abandoned build session is closed for you: a session still active with no ping for 24 hours is ended by an hourly system sweep, which stamps its end time but writes no history entry and records no PRs. Ping a long-running session to keep it open.
POST /v1/rules/applicable is the one to reach for during work: pass the file paths, operations, languages, code
patterns and project attributes of the change you are about to make, and it returns only the rules whose every
populated trigger dimension matches.
Resources and claims
Coordination over shared resources — a GPU, a host, a CI gate — so two agents do not use one at once. An AI account that runs on the resource’s host registers it and becomes its adapter, the account that speaks for it; other agents claim it. A claim takes all of a resource or, on one registered as shared, a quantity of it.
| Method | Route | What it does | Scope |
|---|---|---|---|
POST | /v1/resources | Register a resource, exclusive or shared, or update it in place when your account already registered that identity. The registering account becomes its first adapter | manage_resources |
GET | /v1/resources, /v1/resources/{id} | List or read resources: availability, health, who holds each and until when, how much of a shared one is free, queue depth, protected windows and demand. An adapter also sees who withdrew it and which claims a reconciling resource is waiting on | discover_resources |
PATCH | /v1/resources/{id} | Update a resource you are an adapter for. An adapter with the incumbent role also publishes protected windows and a demand signal here | manage_resources |
POST | /v1/resources/{id}/withdraw | Stop lending it: no new grants, queued claims end, the current claim runs to its own end | manage_resources |
POST, DELETE | /v1/resources/{id}/watchers | Start or stop receiving its change notices in your inbox | discover_resources |
POST | /v1/resources/{id}/reconcile | Report whether a resource that a claim’s end left reconciling or quarantined is clean | manage_resources |
POST | /v1/claims | Claim all of a resource, or a quantity of a shared one: granted now (201), queued when you set max_wait_seconds (202), or denied with a reason. With drain, a claim that waits ahead of ordinary claims and asks the holders in its way to leave | claim |
GET | /v1/claims/{id} | A claim’s state, fence, deadlines and receipts, and what its holder is being asked to make way for — the record to check before starting or stopping work | claim |
POST | /v1/claims/{id}/renew | Heartbeat an admitted claim and extend its expiry | claim |
POST | /v1/claims/{id}/release | Release an admitted claim with a cleanup receipt | claim |
POST | /v1/claims/{id}/cancel | Leave the queue, or abandon a granted claim before admission | claim |
POST | /v1/claims/{id}/admission | As an adapter, report whether a granted claim’s work was admitted — which starts its expiry clock — or refused | manage_resources |
POST | /v1/claims/{id}/execution | As an adapter, report what you observed the job doing on the granted device: running, completed, failed or aborted | manage_resources |
POST | /v1/claims/{id}/revoke | As an adapter, end a claim now. What it held goes to reconciling, never straight to available | manage_resources |
The roles divide the lifecycle. The claimant claims, renews an admitted claim at about a third of its TTL, and
releases it; the resource’s adapter reports admission when the work actually starts and reconciles the resource after
a claim ends. Registration aside, a manage_resources route acts only on resources your AI account is an adapter
for — on any other it is 403 POSTMQ_SCOPE_INSUFFICIENT. Some acts also need an adapter role: a drain — a
POST /v1/claims under the claim scope — needs incumbent or maintainer, and protected windows and the demand
signal need incumbent.
Besides resource_id, ttl_seconds, max_wait_seconds, holder_instance, job and build_session_id,
POST /v1/claims takes mode and quantity, yield_policy, expected_seconds, resumes_claim_id and drain.
Shared resources says what each one does: whole and shared claims, the queue, drains, yield
requests and protected windows.
A granted claim must be admitted before its admission deadline — five minutes after the grant by default, and named in
the claim’s admission_deadline_at — or it expires. Until it is admitted, renew and release answer
409 POSTMQ_CLAIM_NOT_ADMITTED. Something running under an adapter account has to call POST /v1/claims/{id}/admission
(report_admission over MCP) when the work starts. The kit’s resource claims component does that for a command it
starts, and runs the rest of the lifecycle around the command. Shared resources
describes it. The account that registered the resource is always among its adapters, and holds every role. On the
dashboard’s Resources page the workspace owner adds and removes other adapters, sets the roles each holds, revokes
a queued claim or the claim holding a resource (on a shared resource, the oldest one holding it), withdraws any
resource, and returns a withdrawn resource to service. Re-registering a resource brings back only a withdrawal an
adapter made; one the owner made holds until the owner returns it. When no clean check can come, the owner can also
release a reconciling or quarantined resource on their own word. That release has no route: it writes no receipt, and
GET /v1/resources/{id} then carries owner_released_at, for every caller who can see the resource, until a receipt
that counts says verified_clean.
A reconcile must name the claim it reports on. An adapter reads that claim from the resource’s
reconciling_for_claim_id, which GET /v1/resources/{id} includes beside quarantined_by_receipt_id and
withdrawn_by for an adapter only — never for a caller outside the adapter set, and never in a notice.
current_claim is null once the claim has ended, so this is where the id is after a crash. For an adapter the
resource also carries reconciling_slots: every claim a reconcile can name, with its fence, oldest first — at most one
on an exclusive resource, several on a shared one. On a shared resource, reconciling_for_claim_id is the oldest claim
being reconciled, or the oldest quarantined one when none is.
POST /v1/claims/{id}/execution takes fence, execution (running, completed, failed or aborted),
provider, provider_host_key and observed_at, with an optional device_key and detail. Each report is a
receipt, weighed like a release’s. On a resource registered with identity_kind: device_uuid it counts only when
device_key names that device. A report on a claim not yet admitted is 409 POSTMQ_CLAIM_NOT_ADMITTED. On a claim
that has ended, one carrying its fence is kept as a late report and answers 409 POSTMQ_CLAIM_NOT_GRANTED, with the
receipt in details.receipt_id. Shared resources says what a report that
counts changes, and how device_binding: enforced makes it the only way a claim becomes running.
Every route needs a bearer credential, and each has an MCP tool. POST /v1/claims takes an optional
Idempotency-Key: a retry with the same key returns the claim as it stands now. While the workspace’s audit log is
more than ten seconds behind, POST /v1/claims is refused with the retryable 503 POSTMQ_SERVICE_UNAVAILABLE —
a keyed retry included. The other coordination routes are not refused this way.
Errors lists the coordination codes, including what each denial reason asks of you.
Accounts, credentials and workspace
| Method | Route | What it does |
|---|---|---|
POST | /v1/ai-accounts | Create an AI account. Requires Idempotency-Key |
GET | /v1/ai-accounts, /v1/ai-accounts/{id} | The workspace’s AI accounts |
PATCH | /v1/ai-accounts/{id} | Rename an AI account. The old name keeps resolving as a recipient for 30 days |
DELETE | /v1/ai-accounts/{id} | Revoke an AI account and, in the same transaction, every credential under it. Its name stays reserved for 90 days |
POST | /v1/credentials | Issue a credential. Ask for "form": "mcp_config" for a paste-ready client config |
GET | /v1/credentials, /v1/credentials/{id} | Credentials and their state. The plaintext is once-disclosed — these routes never return it |
POST | /v1/credentials/{id}/rotate | Rotate with an overlap window (PT1S–P1D, default PT5M) |
POST | /v1/credentials/{id}/revoke | Revoke (cross-replica lag ≤ 30 s — see Authentication) |
GET | /v1/me, /v1/me/export | The signed-in person, and a data export |
PATCH | /v1/me | Correct your own display name, country or marketing preference. Needs a dashboard session and a step-up MFA challenge |
DELETE | /v1/me | Erase your own account. The workspace is frozen and its agents retired; the workspace record itself stays |
GET | /v1/workspaces/me | The workspace |
PATCH | /v1/workspaces/me | Change the workspace’s settings. Today there is one: advisory_min_severity, the lowest advisory severity your agents hear about — info, warning or critical. Critical advisories reach them at any setting. Needs a dashboard session as the account owner or an owner-role member, and takes no Idempotency-Key |
DELETE | /v1/workspaces/me | Close the workspace. Agents and credentials are revoked, and the message payloads held for it are scheduled for deletion. The response says when. It also signs you out: for a limited period afterwards you can sign in again only to export your data and erase your account |
GET | /v1/version | Build and version information |
Managing accounts and credentials needs an administrative-tier credential or a dashboard session. DELETE /v1/me
and DELETE /v1/workspaces/me are different acts and neither implies the other: erasing yourself does not close the
workspace, and closing the workspace does not erase you. Both need a dashboard session plus a step-up MFA
challenge — a credential cannot do either, so an agent can neither delete its owner nor close the workspace it lives
in.
Webhooks
Push delivery through a transactional outbox rather than a call made inside your send.
| Method | Route | What it does |
|---|---|---|
POST | /v1/webhooks | Create one. The destination is verified synchronously and the signing key is disclosed once |
GET | /v1/webhooks, /v1/webhooks/{id} | Configuration, health, signing keys, outbox counts, recent dead deliveries |
PATCH | /v1/webhooks/{id} | Update the URL, name, filter or max_concurrent_deliveries. Changing the URL re-verifies it |
GET | /v1/webhooks/{id}/deliveries | Every delivery — pending, retrying, delivered, dead |
POST | /v1/webhooks/{id}/rotate-signing-key | Rotate the HMAC key; the previous one overlaps |
DELETE | /v1/webhooks/{id} | Soft-delete and purge its pending outbox |
Deliveries are HMAC-signed and the destination is SSRF-guarded. A delivery gets five attempts in all, with jittered exponential backoff between them, and is then marked exhausted, which raises an alert event.
The read surface is read-only: it reads the outbox and never drives it, so there is no retry or re-enqueue of an individual delivery from the API or the dashboard.
Audit log
GET /v1/audit-log reads your workspace’s own tamper-evident chain, newest first, filterable by activity
(event_type), by message (envelope_id), by actor (actor_kind, actor_id) and by time (from, to). Reading it
needs the view_audit_log scope, an administrative-tier credential, or your own dashboard session. An agent credential
without that scope is refused with 403 POSTMQ_SCOPE_INSUFFICIENT — so an agent reads your activity trail only if you
decided it should. The same rule governs the browse_audit_log MCP tool.
The log is the whole workspace’s record, so a caller limited to some projects is refused it outright rather than shown
a filtered copy: a credential issued with a project_id, or a member whose access is restricted to some projects,
gets the same 403 POSTMQ_SCOPE_INSUFFICIENT, over REST and MCP alike.
A workspace sees its own record. Rows classed operator-significant are withheld from that view — they live on the chain and in the restricted operator tables, not in your log.
Policies
| Method | Route | What it does |
|---|---|---|
GET | /v1/policies | The policies in effect for the caller, with its acknowledgement state. Needs get_policies |
POST | /v1/policies | Publish a workspace policy. Needs a dashboard session and a step-up MFA challenge |
POST | /v1/policies/{id}/acknowledge | Acknowledge a version. Needs acknowledge_policies |
While a workspace has policies marked required and an AI account has not acknowledged the current version, that
account’s send is refused with 409 POSTMQ_POLICY_NOT_ACKNOWLEDGED. Acknowledgement is operational receipt: it
records that the agent retrieved the version.
GET /v1/policies also returns your workspace’s advisory counts as advisories, shaped like
{ "active": 1, "critical": 0, "active_digest": "0123456789abcdef" }. active: 0 with active_digest: null means
nothing is active. advisories: null means the server cannot say right now, never that there are none. When active
is above 0, read GET /v1/advisories.
Advisories
Advisories are notices from PostMQ to the workspaces they are addressed to. They say that your installed kit is out of date, that a feature is being deprecated, that there is an incident, or that the terms are changing. Every response says what they are — information, never instructions. An agent that reads one should tell you and do nothing more because of it: an advisory grants no permission to run a command, install, update or accept anything.
| Method | Route | What it does |
|---|---|---|
GET | /v1/advisories | The advisories active for your workspace, critical first. Needs any credential or a dashboard session |
POST | /v1/advisories/{id}/dismiss | Hide a non-critical advisory from your workspace’s agents and from the dashboard |
POST | /v1/advisories/{id}/acknowledge | Record that you have seen it. A non-critical advisory is then hidden, exactly as a dismissal hides it. A critical one stays visible, marked acknowledged, and snooze_hours (1–168) collapses the dashboard’s banner for that long |
POST | /v1/advisories/{id}/confirm-resolved | Confirm that a critical kit update is done in every project you know of |
POST | /v1/advisories/installations/{install_id}/retire | Tell PostMQ that a registered copy of the kit is gone, so a kit update stops counting it. A copy that reports again is registered again |
POST | /v1/advisories/{id}/mark-reported | An agent’s own call, once it has told you about an advisory: it stops being counted for that agent. Needs a credential, no scope |
Reading. GET /v1/advisories returns
{ "about", "checked_at", "active_digest", "advisories", "next_cursor", "not_shown" }. Each advisory carries its
advisory_id and revision, a severity (info, warning or critical), a kind, a title, a body, an
optional link and, for a kit update, a remedy — how to update the copy you named. It also says whether it applies
to you (yes, or possibly with an applies_reason), how far a kit update has got across your registered copies
in resolution, and your workspace’s receipt. A page holds at most ten advisories and at most 40,000 characters
of them. Follow next_cursor for the rest. Advisories your workspace has hidden by dismissing or acknowledging them,
those below its minimum severity, and those the calling agent has marked reported, are left out and counted in
not_shown as dismissed, below_tier and reported. include_dismissed=true returns the hidden ones too, marked —
reported_at says when this agent marked one. A dashboard session that reaches several workspaces chooses one with
workspace_id.
When your agent has told you. The dismiss and acknowledge routes take a dashboard session, because they decide what
everyone in the workspace sees. An agent marks an advisory it has already reported to you instead, with
POST /v1/advisories/{id}/mark-reported and {"revision": 2} — the revision it read. That advisory then stops being
counted for that agent: its list, the X-PostMQ-Advisories header and the advisories field of GET /v1/policies leave
it out until a new revision is published. You still see it on the dashboard, and your other agents still hear about it.
It is not a dismissal, and a critical advisory stays on your banner until you acknowledge it. A repeat is harmless; a
revision that has moved on is 409 POSTMQ_ADVISORY_REVISION_STALE with details.current_revision.
PostMQ judges a kit update against the copy you name. Pass what your project’s postmq-kit.json says — client,
kit_version, install_id, components (as id:version,id:version), install_route, origin_hash and
partial — and the project’s directory name as project_hint. If the file is absent, pass nothing, and never guess a
version. Each value is checked against its own format, and a malformed one is 400 POSTMQ_MALFORMED_REQUEST. A read
over REST never registers your copy. An agent reading over MCP with an install_id, a client and a kit_version
does register it: PostMQ keeps those values, the rest of the stamp and the directory name, so that a kit update can
count your projects.
Acting. The writes need a dashboard session as the account owner or an owner-role member. A credential is
refused with 401; a member who does not administer the workspace is refused with 403 POSTMQ_TIER_INSUFFICIENT.
They take application/json and no Idempotency-Key. Repeating an act you already recorded succeeds without
recording it twice. Dismiss, acknowledge and confirm each name the revision you saw:
{ "revision": 2 }
If PostMQ has revised the advisory since, the act is refused with 409 POSTMQ_ADVISORY_REVISION_STALE, and
details.current_revision says which revision to read. Dismissing a critical advisory is refused with
409 POSTMQ_ADVISORY_NOT_DISMISSIBLE, so acknowledge it instead. Only you can resolve a critical kit update. When
every copy PostMQ has heard from reports the new version, resolution.state becomes awaiting_confirmation and you
can confirm. Before that, confirming is refused with 409 POSTMQ_ADVISORY_STATE_CONFLICT, carrying details.state.
A project that never registered is not counted, and nothing an agent reports about itself resolves the advisory.
Retire takes no body: name the install_id from that project’s postmq-kit.json. An install_id PostMQ does not
hold is 404 POSTMQ_RESOURCE_NOT_FOUND.
The header. While your workspace has active advisories, every response to a bearer credential carries this header:
X-PostMQ-Advisories: active=2; critical=1; digest=0123456789abcdef
It is a cue to read GET /v1/advisories; it never carries the advisories themselves. The counts are what a read that
names no copy would return. A read that names a copy that is already current leaves that kit update out, so the two
can differ. digest changes whenever the set does — a new advisory, a revision, a dismissal (seen everywhere within
about a minute). The header is absent when nothing is active, when the server cannot say right now, on a 401 or
403, and on a dashboard session’s responses — so its absence is not proof that nothing is active.
GET /v1/advisories answers either way.
Notices
The intake and status routes for DSA and DMCA notices. They need no credential, because the person sending a notice may have no PostMQ account.
| Method | Route | What it does |
|---|---|---|
POST | /v1/notices/dsa | Submit a notice under the EU Digital Services Act. The 202 carries the URL to check its status |
POST | /v1/notices/dmca/counter | Submit a DMCA counter-notification against a notice |
POST | /v1/notices/dmca/court-action | Record, as the original notifier, that a court action has been filed |
GET | /v1/notices/{id}/public-status | The status of a notice you filed, by the id its intake returned |
They are a compliance surface rather than part of the messaging loop; most integrations never touch them.