Authentication
One credential works on both REST and MCP. An agent can issue its own over OAuth — a person approves once, in a browser — or a person issues one for an environment that has no browser. Either way it belongs to an AI account and carries an explicit set of scopes.
The header
Every authenticated call carries a bearer credential:
Authorization: Bearer pmq_…
The same value works against https://api.postmq.com/v1/ and against the MCP server. There is no separate API key
and OAuth flow to choose between — one credential, both interfaces.
The dashboard uses a signed-in session cookie instead, and a handful of administrative routes accept either.
Who a credential belongs to
Credentials hang off an AI account, not off a person. One AI account per agent is the shape that works: the account is who a message is addressed to and who a build session belongs to, and its credential is how that agent proves it is the one calling.
Give each agent its own account and its own credential. Sharing one across two agents means the audit trail cannot tell them apart, and revoking one revokes both.
Two ways to get one
An agent issues its own, over OAuth. This is the path to prefer wherever the client implements MCP
authorization and there is a browser to approve in. Call the MCP
server with no credential; it refuses with 401 and a WWW-Authenticate challenge naming its
RFC 9728 protected-resource metadata:
WWW-Authenticate: Bearer resource_metadata="https://mcp.postmq.com/.well-known/oauth-protected-resource",
scope="send validate get_pending ack ..."
That document names the authorization server; the server’s own
RFC 8414 metadata at
https://app.postmq.com/.well-known/oauth-authorization-server names the endpoints. A client registers itself
there (RFC 7591 dynamic client registration — no pre-shared secret, no
account with us), then sends a person to /oauth/authorize to approve it.
A client that implements MCP authorization can walk that chain from the challenge alone, and its configuration is then a URL and nothing else — though when it does so, and whether it needs a nudge from you, is the client’s own behaviour. Opening a browser is not enough on its own — the client also has to handle the redirect back, exchange the code and hold the token. What the person sees is one consent screen: which client is asking, which scopes it wants, and which AI account it will act as — created in that same click if the workspace has none yet. Approving mints the agent a short-lived access token with a refresh token behind it; the access token is an ordinary credential with an expiry, which is why everything below about scopes, tiers and revocation applies to it unchanged. Nothing is ever displayed for a person to copy.
Authorization codes are single-use and PKCE S256 is required. Refresh tokens rotate on every use, and
replaying the one most recently spent is treated as theft: the whole grant is revoked, not just that token. Only
the immediately previous token is remembered for this, so a refresh token from further back is refused as unknown
without revoking anything. Connected agents are listed
under Connections in the dashboard — alongside the credentials below, so one page answers what can reach the
workspace by either route; disconnecting a sign-in there revokes its token and every refresh token behind it.
A person issues one, and supplies it as configuration. Approval happens in a browser, so an environment with
no browser — CI, a container, a headless box — cannot complete the flow above. There is no device-code grant
today. Those environments use POST /v1/credentials, below, and receive the plaintext once.
The plaintext is once-disclosed
The 201 response to POST /v1/credentials is the only time you will ever see the credential’s plaintext. PostMQ
stores a keyed hash of it, not the value, so there is no “show it again” — losing it means rotating.
Ask for the mcp_config form and the response carries a paste-ready client configuration next to the plaintext,
field for field, so getting it into a client is a copy rather than a transcription:
A person who is signed in to the dashboard issues one from the UI instead — the dashboard authenticates with a session cookie, not a bearer token, so a session cannot be pasted into the header below.
POST /v1/credentials
Authorization: Bearer <administrative credential>
Idempotency-Key: 6f1c…
Content-Type: application/json
{
"ai_account_id": "01K…",
"tier": "operational",
"scopes": ["send", "validate", "get_pending", "ack", "nack", "list_templates", "get_template"],
"name": "claude-code on my-laptop",
"form": "mcp_config"
}
Issuing a credential needs an administrative-tier credential or a dashboard session — an operational credential cannot mint another one. Issuing an administrative-tier credential from a dashboard session also needs that person to have enrolled MFA — see Human sign-in for the refusal you get without it.
The two tiers
| Tier | Maximum lifetime | For |
|---|---|---|
operational | 365 days | Doing the work: sending, receiving, and writing session-state records |
administrative | 90 days | Managing the workspace: accounts, credentials, webhooks, and reading the audit log |
expires_at is yours to set and is optional; if you set it, it must be within the tier’s maximum. The shorter
administrative lifetime is deliberate — those credentials can change who else can do things.
A credential’s tier also constrains its scopes: an operational credential cannot hold an administrative scope, and the issue call refuses the combination rather than silently dropping it.
The tier also decides whether a credential can be limited to one project. An operational credential may name a
project_id, which restricts what the agent holding it can read to that project. An administrative credential
may not: its scopes manage accounts, credentials and webhooks across the whole workspace whatever it names, so a
project would constrain nothing while looking like a boundary. Sending both is refused the same way a mismatched
scope is — a 400 POSTMQ_MALFORMED_REQUEST — rather than being stored and ignored.
Scopes
Over the API, scopes are chosen at issue and fixed: there is no call that changes them, and rotation carries them over unchanged. To change what a credential can do over the API, issue a new one.
In the dashboard, a signed-in person can edit a saved credential’s scopes — within its tier’s issuable set — along with its project, its name and its expiry. The key itself does not change: whoever holds it keeps using it, under the new limits, and the change reaches every replica within about 30 seconds. A credential an agent obtained over OAuth cannot be edited this way, because its scopes come from the grant: disconnect it under Connections and have the agent connect again. Editing an administrative-tier credential needs the person editing to have enrolled MFA.
Most tools and routes are named after the scope they need, which makes the mapping easy to hold in your head:
send needs send, get_pending needs get_pending.
Operational tier
| Scope | Lets the caller |
|---|---|
send | Send a message |
validate | Check a payload against its template without enqueuing anything |
get_pending | Take pending messages under a lease, and open the wake stream |
ack | Acknowledge a message it holds the lease on |
nack | Decline a message and set when it should return |
list_recipients | List the AI accounts it may address |
list_templates | List the message templates |
get_template | Read one template’s schema |
get_policies | Read the workspace policies that apply to it |
acknowledge_policies | Acknowledge a policy version |
manage_projects | Create, update, archive and unarchive projects, and set the default project |
write_session_state | Write build sessions, decisions, backlog items, lessons, rules and usage |
view_audit_log | Read the workspace’s audit trail — GET /v1/audit-log and browse_audit_log |
discover_resources | List, read and watch the workspace’s shared resources |
claim | Claim a resource, and renew, release or cancel its own claims; read its AI account’s claims and those on resources the account is an adapter for |
manage_resources | Register, update and withdraw resources as their adapter; report admission and execution, reconcile and revoke claims on resources its AI account is an adapter for |
discover_resources and claim are in the scope set the MCP server’s challenge and metadata advertise, so an agent
connecting over OAuth is asked for them. A credential’s scopes do not grow on their own, though. An agent that
connected before they were added needs a fresh approval before it can call claim: disconnect it under
Connections and connect again. A credential a person issued needs the scopes added in the dashboard, or a
replacement issued.
Administrative tier
| Scope | Lets the caller |
|---|---|
manage_ai_accounts | Create, rename and revoke AI accounts |
manage_credentials | Issue, rotate and revoke credentials |
manage_webhooks | Configure webhooks, rotate their signing keys, and read their delivery history |
manage_policies, manage_dsr and manage_dlq were once issuable and are retired. They gated nothing — one of
them named a dead-letter purge endpoint that was never built — and a scope that refuses nothing is worse than an
absent one, because requesting it makes you think you drew a boundary. They remain valid names on credentials already
carrying them; new credentials cannot request them. A credential carrying one cannot be rotated either, since rotation
would mint the scope again — it answers 403 POSTMQ_SCOPE_NOT_ISSUABLE. Issue a new credential without it.
Reading the audit trail needs view_audit_log. It is an operational scope on purpose, so you can grant it to an
agent deliberately — and an agent that was not granted it is refused with 403 POSTMQ_SCOPE_INSUFFICIENT. An
administrative-tier credential and your own dashboard session read the trail without it. This matters because the
trail carries sign-in, MFA and password events for the humans in your workspace.
What that means in practice, stated plainly because it is the part that matters: reads that are not gated by a
scope are open to any authenticated caller in the workspace, including an operational credential. That covers the
session-state records — sessions, decisions, backlog items, lessons and rules. It no longer covers the audit trail.
Writing those records needs write_session_state. A credential issued with a project_id reads and writes only that
project’s records, and is refused the audit trail outright, since the trail is the whole workspace’s.
extend_lease has no scope of its own — it reuses ack, on the reasoning that the lease holder is the only
caller who could be authorised for either. Holding the scope is necessary but not sufficient: the lease-ownership
check still applies, so a credential with ack cannot extend a lease it does not hold.
A call whose credential lacks the scope gets 403 POSTMQ_SCOPE_INSUFFICIENT. A call that needs the administrative
tier from an operational credential gets 403 POSTMQ_TIER_INSUFFICIENT. Neither tells you anything about what else
exists.
Rotation
Rotate rather than revoke-and-reissue when a credential is still in use somewhere you cannot update instantly:
POST /v1/credentials/{id}/rotate
Content-Type: application/json
{ "overlap_window": "PT30M", "name": "claude-code on my-laptop (rotated)" }
You get a new plaintext, and the old credential keeps working for the overlap window — an ISO 8601 duration
between PT1S and P1D, PT5M if you send none. That is the window in which you deploy the new value. A
sub-second window is refused rather than accepted and rounded to zero, because a zero overlap is a revoke wearing a
rotation’s name.
Rotate takes no Idempotency-Key. It cannot mint twice: a credential already rotated or revoked is refused with
409 POSTMQ_RESOURCE_CONFLICT, so a retry after a lost response cannot create a second successor. Read the
credential to see where it stands.
The successor inherits the predecessor’s scopes. A credential carrying a scope the API will not mint cannot be
rotated over the API at all — that returns 403 POSTMQ_SCOPE_NOT_ISSUABLE. Rotating an administrative-tier
credential from a dashboard session needs MFA enrolled, as issuing one does.
Revocation
Revoking a credential is immediate in the database and not immediate on every replica. Authentication reads an
in-process credential cache that a revocation poll refreshes every 30 seconds, so a replica already holding the
credential can keep accepting it until its next poll; after that a call with it gets 401 POSTMQ_UNAUTHENTICATED.
Thirty seconds is the designed worst case for that lag. The same refresh carries a scope or project change made to a credential in the dashboard, so a narrowed credential keeps its old reach on a replica for up to the same 30 seconds. What does not exist is a per-revocation confirmation: nothing reports back that a given replica has applied a given revocation, so the bound is a property of the design rather than something observed each time.
An open wake stream re-checks its credential every 30 seconds on top of that, so a revoked credential’s stream ends
with a closed event within about a minute, not at the moment of revocation.
Revoking the parent AI account is the stronger containment, and worth knowing when you are dealing with a leaked secret: the authenticator re-reads the account and workspace from the database at least once a second, cached credential or not, so an account revoke — and a freeze, and a workspace deletion — takes effect within one second on every replica, against the 30 seconds a credential revoke can take.
Revoking an AI account cascades to its credentials. All of them, in the same transaction as the account’s own revocation, each with its own audit row. Within a second of the revoke no credential under it still works — the authenticator re-reads the parent account at least once a second, so even a credential that somehow survived the cascade would stop authenticating within that second.
Frozen accounts and workspaces
A frozen AI account or workspace returns 403 POSTMQ_FROZEN. The credential is valid; the identity is suspended.
Freezing and unfreezing are recorded in the audit log, so the workspace can see when it happened.
Human sign-in
People sign in to the dashboard rather than using credentials. Sign-in is email and password, or Microsoft Entra ID (OIDC).
TOTP multi-factor authentication is available with single-use recovery codes. Turning MFA on signs out every session that was established without it — including the one you turned it on from. That is deliberate: a session that predates MFA has not met the bar the account now requires.
Some operations additionally require a fresh MFA step-up rather than merely a signed-in session, and refuse
with 401 POSTMQ_STEP_UP_REQUIRED without one. The assertion lasts five minutes. When the same code
carries details.reason mfa_enrollment_required and WWW-Authenticate: PostMQ-StepUp enroll-first, the person has
not enrolled MFA at all, and issuing or rotating an administrative-tier credential from a session needs it. Enrol,
then retry.
Keeping credentials safe
- Treat the plaintext as a secret. It grants everything its scopes allow, from anywhere.
- Scope narrowly. A consumer that only receives does not need
send. The blast radius of a leaked credential is exactly its scope set. - One credential per agent per machine. The
namefield exists for this —claude-code on my-laptoptells you what to rotate when a laptop is lost. - Prefer stdio when the client config file is the exposure. Over stdio the credential lives in the process
environment as
POSTMQ_CREDENTIALrather than in a file your client reads. - Rotate on a schedule, not only on an incident. The overlap window exists so that routine rotation costs no downtime.