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.
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, policies, 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
Scopes are chosen at issue and cannot be widened afterwards. To change what a credential can do, issue a new one or rotate — there is no grant call.
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 | Claim pending messages under a lease |
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 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 |
Administrative tier
| Scope | Lets the caller |
|---|---|
manage_ai_accounts | Create, rename, freeze and revoke AI accounts |
manage_credentials | Issue, rotate and revoke credentials |
manage_webhooks | Configure webhooks and rotate their signing keys |
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.
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.
Reading session-state records — sessions, decisions, backlog, lessons, rules — needs only an authenticated caller,
not a specific scope. Writing them needs write_session_state.
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
Idempotency-Key: …
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. 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.
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.
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, and it applies to credential revocation and nothing else. 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.
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 on every call, cached credential or not, so an account revoke — and a freeze, and a workspace deletion — takes effect on the very next request with no propagation window at all.
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. There is no state in which a revoked account still has a working credential — and the authenticator re-reads the parent account on every single call, so even a credential that somehow survived the cascade would not authenticate.
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.
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.