Skip to main content

REST API (v1)

Your gateway serves a plain JSON API over HTTP. The console, the permaura CLI and your own scripts all drive the same routes, so anything you can click you can automate.

  • Base URLhttp://127.0.0.1:7376 by default, or your gateway's public URL if remote access is on.
  • AuthAuthorization: Bearer <operator token> on every route except /health.
  • Content typeapplication/json in and out.

For a full worked example that exercises the whole surface in one pass, see Worked example: Paddle.

Getting a token

On a clean install the gateway mints an operator token and writes it to ~/.permaura/operator.token:

export PERMAURA_TOKEN=$(cat ~/.permaura/operator.token)
curl -s http://127.0.0.1:7376/health | jq

PERMAURA_TOKEN in the environment overrides the file, and is what to set if you provision the gateway yourself.

Two token roles

Tokens carry a role. An operator token drives the routes on this page. An agent token, minted when an agent enrols over MCP, is for /mcp: every operator-plane mutation refuses it outright rather than partially working.

That now includes reads. GET /v1/connections, /v1/policies, /v1/grants and /v1/secrets used to answer an agent token, so an agent could read the shape of your security config (never a secret value). They are operator-only as of gateway 1.2.0.

Health

GET /health needs no token and is the right liveness check:

curl -s http://127.0.0.1:7376/health
{
"status": "ok",
"service": "permaura",
"version": "1.0.0",
"agents": 2,
"capabilities": 14,
"grants": 3,
"audit_chain_intact": true
}

audit_chain_intact: false means the hash-chained audit log no longer verifies — treat it as an incident, not a warning.

Two conventions worth knowing up front

202 does not mean "done"

Any call that needs a human returns 202 Accepted with an approval_id, not a result. That covers both an agent's POST /v1/invoke hitting a needs-approval rule, and an operator mutation that arrived over the network once a signing device is paired:

{
"approval_id": "appr_9f2c…",
"status": "requires_approval",
"required_tier": "device_signed",
"message": "Confirm control-plane change: …"
}

Nothing has happened yet. Poll GET /v1/approvals/:id, or resolve it with POST /v1/approvals. Treat 202 as pending, never as success — it is the single easiest thing to get wrong when building on this API.

Policies want the bare action id, capabilities want the qualified one

The same action is named two ways depending on the route, and getting it wrong on a policy fails silently.

For a connection db1 exposing uptime, the qualified id is db1.uptime and the bare id is uptime.

RouteWantsExample
POST /v1/policiesbare{"connection_id":"db1","allow":["uptime"]}
POST /v1/connections/:id/capabilitiesqualified{"capability":"db1.uptime"}

Policy matching is exact equality on the bare id. A policy written with qualified ids is accepted with a cheerful 201 and then matches nothing at all — including its deny rules, which is the dangerous half. You get a policy that looks live in every listing and enforces nothing.

Verify a scripted policy actually bites

After writing a policy over the API, prove it: invoke something the policy should deny and confirm you get a denial in the audit log. Don't take the 201 as evidence. The console isn't affected by this — it derives the bare id for you — so this is a REST, CI and scripting trap only.

Some routes are loopback-only

A handful of endpoints mint or trust authority with no credential. They only answer a loopback client on the gateway's own machine, because being able to talk to loopback is the proof that a human is at that machine.

Two of them are loopback-only everywhere, on every placement, with no exception:

RouteWhy
POST /v1/sessionsmints an operator token from nothing
POST /v1/shutdownstops the process

Two more are loopback-only on a local or tunnelled gateway, and open on a hosted worker behind a substitute:

RouteLocal, tunnelled, proxiedHosted
POST /v1/approver-keys/pairrefused (403)owner-scoped operator token plus a single-use pairing ticket
PUT /v1/approval-floor, lowering onlyrefused (403)same; raising the floor works anywhere on every placement

The asymmetry is deliberate rather than an oversight. A tunnelled gateway has a host, so its owner can always walk to the machine and the positional proof stays available; a hosted worker has no loopback client for anyone to be, so leaving these closed would mean a hosted tenant could never enrol an approval device at all.

One further hosted rule uses the same ticket without being a loopback rule at all. On a hosted worker, revoking the last usable approver key takes a ticket of its own. Emptying the keyring returns the worker to trusting the next device on first use, so it is the one revocation that loosens rather than tightens, and it costs what enrolling costs. Both routes that can empty the keyring are covered: DELETE /v1/approver-keys/:approver_id, and DELETE /v1/devices/:id, which revokes the device's signing key on the way past.

Revoking a device while another usable one remains needs nothing beyond the operator token, anywhere. Neither does any revocation on a local, tunnelled or proxied gateway: none of this applies there. Revoking a device whose key is already revoked, or that never carried one, does not empty anything and is never gated either.

The gateway classifies a request as remote from the forwarding headers an HTTP tunnel adds plus the TCP peer address. This is also why a raw TCP forwarder must never be pointed at a gateway: with no forwarding headers, internet traffic would look local and reach exactly these routes.

The pairing ticket (hosted gateways only)

A pairing ticket is a short-lived JWT that permaura.com issues only after you re-authenticate, signed by the same key set the gateway already trusts for operator tokens and verified entirely offline. Present it in the X-Permaura-Pairing-Ticket header alongside the usual bearer.

"Pairing ticket" is the generic name for all three of them. Each purpose is a separate token class rather than a flag on one, with its own audience and its own single-use ledger, so a ticket issued for one loosening is inert on the other two.

The gateway checks all of this before the request is allowed to touch anything:

CheckDetail
Audience<public URL> joined to the one thing the ticket authorises: /v1/approver-keys/pair, /v1/approval-floor, or /v1/approver-keys/revoke-last. Deliberately not the /mcp resource, so an operator access token can never stand in for a ticket
Purposeapprover-pair, approval-floor-lower or approver-revoke-last; a ticket for one is worthless on the others
BindingFor pairing, the exact approver_id and the fingerprint of the exact public key in the body. The other two carry no binding, because at any moment there is exactly one floor and exactly one revocation that empties the keyring
Freshness180-second TTL, and the asserted re-authentication (auth_time) must be under 300 seconds old
FactorThe asserted amr must include one of pwd, otp, totp, passkey. A session cookie is not a re-authentication
Single useThe jti is spent in the gateway's database before the write, so a replay fails even against a different instance of the same worker
Same personThe ticket's subject must equal both the gateway's bound owner and the operator session presenting it
/v1/approver-keys/revoke-last is an audience, not a route

There is no endpoint at that path and calling it does nothing. The audience names the action because two routes perform it, DELETE /v1/approver-keys/:approver_id and DELETE /v1/devices/:id; binding it to either route's path would mint a ticket the other one refused. Send the ticket in the header on whichever of the two you actually call.

Call it without a ticket and you get 401, not 403. The difference matters: 401 means "go and re-authenticate", and the message names where:

{
"error": "step-up required: this action needs a fresh 'approver-pair' pairing ticket in the x-permaura-pairing-ticket header; re-authenticate at the authorization server described by https://g-xxxx.permaura.com/.well-known/oauth-protected-resource/mcp to obtain one"
}

An expired, mis-bound or already-spent ticket answers 401 the same way. A ticket minted for a different account than the session presenting it is 403, as is any of these calls arriving remotely at a non-hosted gateway.

There is no WWW-Authenticate header on that response: the pointer is in the message text. If you are writing a client, match on the 401 and read the body rather than looking for a challenge header.

Pairing answers 200 or 202

The first approval device on a hosted worker is trusted immediately. There is nothing yet in the keyring that could confirm it, so the ticket is the whole authorisation:

{ "approver_id": "device:a1b2c3", "trusted": true, "key_fp": "9c2e4a17b3d05f81" }

Every device after it is held until a device already in the keyring signs the enrolment, and the response is a 202 carrying the extra fields you need to tell a pending enrolment from a pending policy edit:

{
"approval_id": "appr_9f2c…",
"status": "requires_approval",
"required_tier": "device_signed",
"message": "Confirm trust-root change: Trust approver device 'device:a1b2c3' …",
"action": "permaura.approver.pair",
"approver_id": "device:a1b2c3",
"key_fp": "9c2e4a17b3d05f81"
}

The device being enrolled cannot sign off its own enrolment: the signature is resolved against the keyring, which by definition does not hold it yet.

Lowering the approval floor on a hosted worker behaves the same way. It needs a ticket, and while any usable device is still trusted it also needs that device's signature; the ticket alone suffices only when no usable device is left, which is the recovery case. Every enrolment, refusal and revocation is written to the audit chain under permaura.approver.pair.

Emptying the keyring is a step-up too (hosted only)

Revocation is otherwise fail-safe, so it stays reachable with an operator token alone on every placement. Removing the last usable approver key is different in kind: it flips the trust root from "a co-signature is required" back to "trust the next device on first use". On a hosted worker that one revocation therefore takes its own ticket, approver-revoke-last.

Without it, anything holding an operator token could clear the keyring with no re-authentication at all, then walk straight through the re-opened first-use branch with a key of its own. The purposes are separate token classes precisely so that cannot be done with one credential: an approver-pair ticket presented on a revocation is refused for the wrong purpose and the wrong audience, and the reverse holds too.

RequestTicket
DELETE /v1/approver-keys/:approver_id, another usable key remainsnone, any placement
DELETE /v1/approver-keys/:approver_id, the last usable key, hostedapprover-revoke-last
DELETE /v1/devices/:id, the device holds the last usable key, hostedapprover-revoke-last
Either route on a local, tunnelled or proxied gatewaynone, unchanged

The gateway answers the same 401 step-up challenge as pairing when the ticket is missing, naming approver-revoke-last in the message. The check and the revocation run under one lock, so two concurrent revocations cannot each see the other's device as the survivor and empty the keyring between them.

For a hosted tenant whose only device is gone, that makes recovery two re-authentications rather than one: revoke the dead key, then pair the replacement. Playbooks walks it through.

Routes

Grouped by what they manage. Every route is prefixed /v1.

Connections

MethodPathDoes
GET POST/connectionslist; create one
GET PATCH DELETE/connections/:idfetch; rename, enable/disable; remove
POST/connections/:id/credentialseal its credential (write-only, never read back)
POST/connections/:id/capabilitiestoggle one capability on or off
POST/connections/from-presetcreate from a catalog preset; optional base_url / static_headers pin an account-specific host or tenant header
POST/connections/:id/oauth/startbegin the bring-your-own-app OAuth flow; returns the provider authorize_url
POST/connections/import-openapiderive one from an OpenAPI document
GET/presetsthe catalog presets this gateway carries
GET POST DELETE/connectors/installed, /connectors/install, /connectors/:idruntime-installed connector manifests

POST /connections/:id/oauth/start takes {client_id, client_secret, redirect_uri} and an optional scopes array, and answers {authorize_url, expires_in_ms, pkce}. Open the URL, approve, and the provider redirects to GET /oauth/callback on the gateway — the one route outside /v1 and the one route with no bearer token, because it is a plain browser navigation. Its authentication is the state parameter: 32 random bytes, single-use, and valid for ten minutes. The gateway exchanges the code itself and seals the tokens as the connection's credential, then refreshes them on its own from then on. Requires gateway 1.1.0.

Starting the flow is refused over a tunnel while an approver device is enrolled: it ends in a sealed credential, which is a held mutation, and a browser redirect cannot carry an approval. Run it from the gateway's own host in that setup.

Policies and grants

MethodPathDoes
GET POST/policieslist; create a connection-scoped policy
PUT DELETE/policies/:idreplace; remove
GET POST/grantslist; create
PUT PATCH DELETE/grants/:idreplace; amend; remove
GET/grants/:id/usagebudget consumed against this grant
GET/capabilitiesevery capability across every connection

Agents and sessions

MethodPathDoes
GET/agentslist
PUT DELETE/agents/:idrename; remove (revokes its sessions)
POST/agents/enrollmint a one-time enrolment code (ENRL-XXXX-XXXX, 15 minutes, single use)
POST/agents/:id/approveapprove a pending agent
POST/agents/registerregister an agent identity directly
GET/sessionslive sessions
POST/sessionsopen one — loopback-only
POST/sessions/:id/revokekill one immediately

Execution, approvals, audit

MethodPathDoes
POST/invokerun a capability through policy (this is what MCP calls land on)
GET/approvalsthe pending queue
GET/approvals/:idone request's status
POST/approvalsapprove or deny (bearer, or device-signed)
GET/audit-eventsthe hash-chained, signed log

Secrets

MethodPathDoes
GET POST/secretslist ids and metadata only; create
POST/secrets/:id/rotatereplace a value
DELETE/secrets/:idremove

There is no route that returns a secret value, by design. Listing gives you the inventory; reading a value back is not part of the API.

Devices and approver keys

MethodPathDoes
GET/devicesenrolled approval devices
POST/devices/enrollenrol one
DELETE/devices/:idrevoke one, and untrust its signing key; on hosted, needs a revoke-last ticket if that key is the last usable one
POST/devices/push-tokenregister a push token
POST/approver-keys/pairtrust a signing key — loopback-only, except on hosted with a pairing ticket
DELETE/approver-keys/:approver_idrevoke that trust; on hosted, needs a revoke-last ticket if it is the last usable key
PUT/approval-floorset the minimum approval tier (lowering is loopback-only, same hosted exception)

Broker controls

MethodPathDoes
GET/brokerpaused state and the new-agent approval flag
POST/broker/pauseglobal stop — every call refused while paused
POST/broker/require-approvalhold every newly connected agent as Pending
POST/shutdowngraceful stop — loopback-only

Discovery and MCP

When PERMAURA_PUBLIC_URL is set, the gateway is an OAuth resource server and serves /.well-known/oauth-protected-resource (and /mcp, /.well-known/oauth-authorization-server, /.well-known/openid-configuration) so MCP clients can discover where to sign in. POST /mcp is the agent plane — JSON-RPC, not REST.

A quick end-to-end

Create a connection, seal its credential, allow one action, and grant it:

BASE=http://127.0.0.1:7376
AUTH="authorization: Bearer $PERMAURA_TOKEN"

# 1. the connection
curl -sX POST $BASE/v1/connections -H "$AUTH" -H 'content-type: application/json' \
-d '{"id":"gh","connector":"github","name":"GitHub"}'

# 2. seal the credential (write-only from here on)
curl -sX POST $BASE/v1/connections/gh/credential -H "$AUTH" -H 'content-type: application/json' \
-d "{\"value\":\"$GITHUB_TOKEN\"}"

# 3. a policy scoped to it — note the returned pol_… id
curl -sX POST $BASE/v1/policies -H "$AUTH" -H 'content-type: application/json' \
-d '{"connection_id":"gh","allow":["pull_requests.create"]}'

# 4. bind it to an agent
curl -sX POST $BASE/v1/grants -H "$AUTH" -H 'content-type: application/json' \
-d '{"policy_ids":["pol_…"],"agents":[{"agent_id":"codex-cli"}]}'

Check what the gateway decided, either way:

curl -s "$BASE/v1/audit-events?limit=20" -H "$AUTH" | jq
No OpenAPI document yet

There is no published machine-readable schema for REST v1. This page is maintained by hand against the gateway's router, so if something here disagrees with your gateway, the gateway is right — tell us and we'll fix the page.