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 URL —
http://127.0.0.1:7376by default, or your gateway's public URL if remote access is on. - Auth —
Authorization: Bearer <operator token>on every route except/health. - Content type —
application/jsonin 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.
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.
| Route | Wants | Example |
|---|---|---|
POST /v1/policies | bare | {"connection_id":"db1","allow":["uptime"]} |
POST /v1/connections/:id/capabilities | qualified | {"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.
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:
| Route | Why |
|---|---|
POST /v1/sessions | mints an operator token from nothing |
POST /v1/shutdown | stops the process |
Two more are loopback-only on a local or tunnelled gateway, and open on a hosted worker behind a substitute:
| Route | Local, tunnelled, proxied | Hosted |
|---|---|---|
POST /v1/approver-keys/pair | refused (403) | owner-scoped operator token plus a single-use pairing ticket |
PUT /v1/approval-floor, lowering only | refused (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:
| Check | Detail |
|---|---|
| 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 |
| Purpose | approver-pair, approval-floor-lower or approver-revoke-last; a ticket for one is worthless on the others |
| Binding | For 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 |
| Freshness | 180-second TTL, and the asserted re-authentication (auth_time) must be under 300 seconds old |
| Factor | The asserted amr must include one of pwd, otp, totp, passkey. A session cookie is not a re-authentication |
| Single use | The 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 person | The 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 routeThere 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.
| Request | Ticket |
|---|---|
DELETE /v1/approver-keys/:approver_id, another usable key remains | none, any placement |
DELETE /v1/approver-keys/:approver_id, the last usable key, hosted | approver-revoke-last |
DELETE /v1/devices/:id, the device holds the last usable key, hosted | approver-revoke-last |
| Either route on a local, tunnelled or proxied gateway | none, 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
| Method | Path | Does |
|---|---|---|
GET POST | /connections | list; create one |
GET PATCH DELETE | /connections/:id | fetch; rename, enable/disable; remove |
POST | /connections/:id/credential | seal its credential (write-only, never read back) |
POST | /connections/:id/capabilities | toggle one capability on or off |
POST | /connections/from-preset | create from a catalog preset; optional base_url / static_headers pin an account-specific host or tenant header |
POST | /connections/:id/oauth/start | begin the bring-your-own-app OAuth flow; returns the provider authorize_url |
POST | /connections/import-openapi | derive one from an OpenAPI document |
GET | /presets | the catalog presets this gateway carries |
GET POST DELETE | /connectors/installed, /connectors/install, /connectors/:id | runtime-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
| Method | Path | Does |
|---|---|---|
GET POST | /policies | list; create a connection-scoped policy |
PUT DELETE | /policies/:id | replace; remove |
GET POST | /grants | list; create |
PUT PATCH DELETE | /grants/:id | replace; amend; remove |
GET | /grants/:id/usage | budget consumed against this grant |
GET | /capabilities | every capability across every connection |
Agents and sessions
| Method | Path | Does |
|---|---|---|
GET | /agents | list |
PUT DELETE | /agents/:id | rename; remove (revokes its sessions) |
POST | /agents/enroll | mint a one-time enrolment code (ENRL-XXXX-XXXX, 15 minutes, single use) |
POST | /agents/:id/approve | approve a pending agent |
POST | /agents/register | register an agent identity directly |
GET | /sessions | live sessions |
POST | /sessions | open one — loopback-only |
POST | /sessions/:id/revoke | kill one immediately |
Execution, approvals, audit
| Method | Path | Does |
|---|---|---|
POST | /invoke | run a capability through policy (this is what MCP calls land on) |
GET | /approvals | the pending queue |
GET | /approvals/:id | one request's status |
POST | /approvals | approve or deny (bearer, or device-signed) |
GET | /audit-events | the hash-chained, signed log |
Secrets
| Method | Path | Does |
|---|---|---|
GET POST | /secrets | list ids and metadata only; create |
POST | /secrets/:id/rotate | replace a value |
DELETE | /secrets/:id | remove |
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
| Method | Path | Does |
|---|---|---|
GET | /devices | enrolled approval devices |
POST | /devices/enroll | enrol one |
DELETE | /devices/:id | revoke one, and untrust its signing key; on hosted, needs a revoke-last ticket if that key is the last usable one |
POST | /devices/push-token | register a push token |
POST | /approver-keys/pair | trust a signing key — loopback-only, except on hosted with a pairing ticket |
DELETE | /approver-keys/:approver_id | revoke that trust; on hosted, needs a revoke-last ticket if it is the last usable key |
PUT | /approval-floor | set the minimum approval tier (lowering is loopback-only, same hosted exception) |
Broker controls
| Method | Path | Does |
|---|---|---|
GET | /broker | paused state and the new-agent approval flag |
POST | /broker/pause | global stop — every call refused while paused |
POST | /broker/require-approval | hold every newly connected agent as Pending |
POST | /shutdown | graceful 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
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.