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
A gateway linked to your account replaces operator.token on its own, so read the file when you need the token rather than keeping it in a long-lived shell variable. From gateway 1.3.46, one with no public URL replaces it shortly after each start, once permaura.com has accepted the console session it hands over, and the old value stops working two minutes later. From 1.3.47 it does so at the gateway's first re-seal pass after each start and each pairing, within about a minute, whatever permaura.com answers, again with two minutes before the old value stops working, and a gateway with a public URL replaces it at every start, with the old value stopping at once.
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 enrolls 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. Without one it says only that the gateway is up:
curl -s http://127.0.0.1:7376/health
{ "status": "ok", "service": "permaura-gateway" }
With a valid operator token it adds the detail, among other fields:
curl -s -H @<(printf 'Authorization: Bearer %s\n' "$PERMAURA_TOKEN") http://127.0.0.1:7376/health
The examples on this page read the Authorization header from a process substitution (-H @<(…), bash and zsh), so the token is never one of curl's arguments, where every local user can read it in the process list.
{
"status": "ok",
"service": "permaura-gateway",
"version": "1.3.49",
"agents": 2,
"capabilities": 14,
"grants": 3,
"audit_chain_intact": true,
"tunnel_ingress": "local",
"tunnel_stop_reason": null
}
audit_chain_intact: false means the hash-chained audit log no longer verifies. Treat it as an incident, not a warning.
From gateway 1.3.47, the detail also says what the gateway knows about a managed tunnel connector it started. On 1.3.47 tunnel_ingress is "remote" once the gateway has started one, whose configuration is then managed in Permaura's Cloudflare account, and null otherwise. From 1.3.48 it is "local" while the connector runs with the configuration the gateway wrote, "stopped" once the gateway has stopped it, and null when the gateway runs none, which includes a tunnel the desktop app runs for it and a connector that exited by itself; tunnel_stop_reason, also from 1.3.48, gives the reason for a stop and is null otherwise. Troubleshooting covers what to do.
From gateway 1.3.51, agents_paused is true on a hosted gateway whose agents Permaura has paused, because its plan has lapsed or it is being removed. Every agent call is refused then, while the console and the vault export keep working; Trials & billing has the detail. GET /v1/broker reports the same field.
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 tunneled gateway, and open on a hosted worker behind a substitute:
| Route | Local, tunneled, 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 tunneled 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 enroll an approval device at all.
Revocation is not loopback-only, but it takes the same kind of ticket. DELETE /v1/approver-keys/:approver_id, and DELETE /v1/devices/:id (which revokes the device's signing key on the way past), take an approver-revoke ticket on a hosted worker, and on a self-run gateway whenever the request is remote. At that machine the operator bearer is enough. A self-run gateway with no remote access configured (no PERMAURA_PUBLIC_URL) cannot verify a ticket, so it refuses a remote revocation with 403 and the advice to revoke from the gateway's own machine. A device row with no approver id, registered for notifications and never paired, has no key to revoke and retires on the operator bearer alone, anywhere. Revoking a key takes a ticket has the detail.
The gateway classifies a request as remote when any of these holds: it is a hosted worker; the request carries a forwarding header an HTTP tunnel or proxy adds (Forwarded, X-Forwarded-For, X-Real-IP, CF-Connecting-IP, CF-Ray); it carries an Origin header other than the desktop app's own webview, which is how a web page, the console included, is recognized; from gateway 1.3.46, its bearer is one permaura.com issued, an OAuth token or the console session permaura.com holds for the gateway; or the TCP peer is not a loopback address. A script on the gateway's machine that sends an Origin header is therefore remote. 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
A pairing ticket is a short-lived JWT that permaura.com issues only after you confirm it's you with a passkey, an approval on your phone or your password (for the two recovery purposes, your password and a second factor, or two different methods on an account with no password), 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 five purposes. 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 is inert on the others.
| Purpose | Audience path | Authorizes | Bound to |
|---|---|---|---|
approver-pair | /v1/approver-keys/pair | enrolling a key on a hosted worker | the approver_id and the fingerprint of the exact public key in the body |
approval-floor-lower | /v1/approval-floor | lowering the floor on a hosted worker | nothing, because there is exactly one floor |
approver-revoke | /v1/approver-keys/revoke | revoking one approver key: every revocation on a hosted worker, and every one not made at a self-run gateway's own machine | the approver_id being revoked |
recovery | /v1/recovery | recovering a self-run gateway with remote access whose device is gone: lowering the floor, or the revocation that empties the keyring; minted after the password and a second factor, or two different methods on an account with no password | nothing |
recovery-codes-issue | /v1/recovery-codes | from gateway 1.3.46, issuing a fresh set of recovery codes on a self-run gateway from off its own machine (the Permaura app), in addition to a device signature or a current code; minted after the password and a second factor, or two different methods on an account with no password, lives 180 seconds, spent once | the gateway; carries the acting member and their role |
The gateway checks all of this before the request is allowed to touch anything:
| Check | Detail |
|---|---|
| Audience | <public URL> joined to the purpose's path above. Deliberately not the /mcp resource, so an operator access token can never stand in for a ticket |
| Purpose | The purpose claim must name the purpose the request needs; a ticket for one is worthless on the others |
| Binding | As in the table above |
| Freshness | 180-second TTL, and the asserted re-authentication (auth_time) must be no more than 300 seconds old |
| Factor | On every purpose but the two recovery ones, pairing included, amr must name at least one of pwd, passkey, device or new-device (permaura.com's name for a phone in its first 24 hours as the account's approver). A ticket naming only totp, otp, backup or session, or nothing, is refused, and the refusal is written to the audit chain. Whether a password alone is enough is permaura.com's call, made before it mints the ticket (a password changed in the last seven days takes a second step, and that ticket names both methods); the gateway checks only that one of the four is there. A recovery or recovery-codes-issue ticket must name pwd and one of passkey, device, totp, otp or backup, or two different methods among passkey, device and totp, or one of those three and backup (the last two are what an account with no password presents). new-device never counts toward a recovery ticket, and neither does otp without pwd. Anything less, one method on its own included, is refused and audited the same way. 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 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. /v1/recovery is the same kind of audience: one recovery ticket covers either of the two controls it allows.
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-revoke' pairing ticket in the x-permaura-pairing-ticket header; re-authenticate at the authorization server described by https://your-gateway.permaura.app/.well-known/oauth-protected-resource/mcp to obtain one"
}
An expired or already-spent ticket answers 401 the same way, as does a pairing ticket bound to a different key. So does a ticket whose amr names none of pwd, passkey, device or new-device:
{
"error": "step-up required: pairing ticket rejected: ticket does not assert a password, passkey or phone approval"
}
A recovery or recovery-codes-issue ticket that falls short of its rule is refused the same way, with its own reason:
{
"error": "step-up required: pairing ticket rejected: ticket does not assert the password and a second factor, two different strong methods, or one strong method and a backup code"
}
For a recovery ticket the refusal reads recovery ticket rejected: in place of pairing ticket rejected:.
An approver-revoke ticket bound to a different approver id is 403 instead, because re-authenticating for the same request would only mint the same mismatch:
{
"error": "denied: revocation ticket refused: ticket is not bound to this approver id"
}
A ticket minted for a different account than the session presenting it is 403 too, as is pairing or lowering the floor arriving remotely at a non-hosted gateway. A self-run gateway with no remote-access configuration has no authorization server to verify a ticket against, so it answers a remote revocation 403 and says to revoke at the gateway's own machine.
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 authorization. That is why, from gateway 1.3.44, a pairing while the worker has no usable key takes a ticket whose actor (act_sub) is the worker's owner, or whose act_role claim says the actor is an owner or admin of the organization. Any other ticket, including one that names no actor, is refused 403 with a message saying who can pair, and the refusal is recorded. A pairing that goes through answers:
{ "approver_id": "device:a1b2c3", "trusted": true, "key_fp": "9c2e4a17b3d05f81" }
Every device after it is held until a device already in the keyring signs the enrollment, and the response is a 202 carrying the extra fields you need to tell a pending enrollment 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 enrollment: the signature is resolved against the keyring, which by definition does not hold it yet.
From gateway 1.3.51, an approver_id of pair, enroll or push-token is refused 400. Those are the fixed path segments beside DELETE /v1/approver-keys/:approver_id and DELETE /v1/devices/:id, so a key paired under one could never be revoked by its id. permaura.com refuses the same three when it issues a pairing ticket. An id that only contains one of them, such as device:pair, is fine.
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 enrollment, refusal and revocation is written to the audit chain under permaura.approver.pair.
The person a key approves for
From gateway 1.3.44 a trusted key can be recorded against the person it approves for, and a two-person rule counts distinct people rather than keys.
- Hosted. The person is the ticket's
act_sub, the account that re-authenticated to pair. Anypersonin the body is ignored, and a ticket that names no actor pairs a key with no person. - Self-run. The pairing body takes an optional
person, recorded as given, since whoever relays the pairing at the gateway's machine vouches for it. Surrounding whitespace is trimmed and an empty value is the same as none; a value over 128 characters, or one containing a control character, is refused400.permaura approver pair --personsets it, overriding anypersonthe pairing blob carries.
{
"approver_id": "device:a1b2c3",
"approver_pubkey": "<base64 ed25519 public key>",
"issued_at": 1760338800000,
"sig": "<base64 signature over the pairing preimage>",
"person": "ada@example.com"
}
A pairing that is applied answers 200 with person_bound, which is false when the key has no person. Such a key can still be the one signature a single-signature rule needs, but it never counts as a second person.
Pairing a key the gateway already trusts, under the same approver id, is how a key paired before 1.3.44 gets a person:
| The trusted key has | The pairing names | Answer |
|---|---|---|
| a person | nobody, or that same person | 200, and the key keeps its person |
| a person | anyone else | 403; revoke the key and pair it again |
| no person | nobody | 200, and the key still has no person |
| no person | a person another trusted key already has, or anyone while no trusted key has a person | 200, recorded at once |
| no person | anyone else | 202, held for a device the gateway trusts to co-sign, with already_trusted: true; the key stays trusted meanwhile |
Every pairing 202 carries already_trusted, which is false when the key is not yet trusted at all.
Revoking a key takes a ticket
Revoking a key looks fail-safe, since one fewer device can sign, but it also decides which devices are left to co-sign the next enrollment, and removing the last usable key flips the trust root from "a co-signature is required" back to "trust the next device on first use". So every revocation that does not come from a self-run gateway's own machine takes an approver-revoke ticket, bound to the one approver id it revokes.
| Request | Hosted | Self-run, at its own machine (loopback) | Self-run, from anywhere else |
|---|---|---|---|
DELETE /v1/approver-keys/:approver_id | approver-revoke | none | approver-revoke; 403 with no remote access configured |
DELETE /v1/devices/:id, the row has an approver_id | approver-revoke | none | approver-revoke; 403 with no remote access configured |
DELETE /v1/devices/:id, the row has no approver_id | none | none | none |
For a device delete, bind the ticket to the row's approver_id from GET /v1/devices, not to its id. The two are the same string only on a row with no registration behind it, such as an Apple Watch.
Without the ticket, 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. 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.
A revocation that goes through answers 200. DELETE /v1/approver-keys/:approver_id says whether it emptied the keyring:
{ "approver_id": "device:a1b2c3", "trusted": false, "emptied_keyring": false }
DELETE /v1/devices/:id answers { "device_id": "device_k3m9x2", "revoked": true }.
From gateway 1.3.46, a revocation on a ticket whose actor (act_sub) is not the gateway's owner, of a key that is not bound to that actor, does not apply at once unless the ticket carries remove_alone, which permaura.com sets for an owner or admin of the organization holding Remove any approval device alone (device.remove.any). Either route answers 202 with an approval_id and holds it for a signature from a device paired to someone other than the actor; the key being revoked may sign its own removal. The ticket is spent either way. The hold lapses 24 hours after it was made, and the revocation then has to be asked for again with a fresh ticket. The gateway's owner, a ticket carrying remove_alone, and anyone revoking a key bound to themselves get the 200 above.
On a self-run gateway, the revocation that would empty the keyring also needs the recovery authority on top of any ticket, because after it nothing is held for a device signature. A recovery code in the X-Permaura-Recovery-Code header applies it at once, as does a recovery ticket where the gateway has remote access; otherwise it answers 202 and is held for a co-signature from a device the gateway still trusts. The approver-revoke ticket is spent either way, even on that 202, so a retry needs a fresh one. A recovery ticket presented on that call stands for both the step-up and the recovery authority, since one header cannot carry two tickets.
On a hosted worker, from gateway 1.3.44, the revocation that would leave no usable key takes a ticket whose actor (act_sub) is the worker's owner, or whose act_role claim is owner or admin. The role is compared by value, so any other value, or none, is a member's standing. Any other ticket, including one that names no actor, is refused 403 on either route, and the refusal is recorded:
{
"error": "denied: revoking this device would leave this gateway with no trusted approver, and only the gateway's owner or an owner or admin of its organization can do that: pair another device first, or ask an owner or admin to remove it"
}
A revocation that leaves another usable key trusted keeps the ordinary ticket rule.
For a hosted tenant whose only device is gone, recovery is two re-authentications rather than one: revoke the dead key, then pair the replacement. Playbooks walks it through.
What the audit chain records
Every revocation is written under permaura.approver.pair, with these fields in its summary:
| Field | Meaning |
|---|---|
approver_id, key_fp | The key that was revoked, read from the keyring |
authority | What permitted it: hosted_owner_ticket, self_run_owner_ticket, operator_revocation (the operator bearer at a self-run gateway's own machine), recovery_code or recovery_ticket |
ticket_jti, auth_time, amr | The ticket that was spent and the re-authentication it asserted, where there was one |
actor_sub, actor_name | Who permaura.com says confirmed it, which on a team account is often not the gateway's owner. null means not named, never "the owner" |
actor_role | owner or admin when permaura.com says the actor holds that role in the organization, otherwise null. Written on a record made with a recovery ticket, and from gateway 1.3.44 on every hosted record |
revoked | true once the key is untrusted |
emptied_keyring | Whether this revocation left the gateway with no usable key |
Routes
Grouped by what they manage. Every route is prefixed /v1.
Connections
| Method | Path | Does |
|---|---|---|
GET POST | /connections | list; create one. From gateway 1.3.46, a connector id that has a catalog preset, an installed manifest or an import route of its own is refused (400): create that connection through its own route, which pins the host its credential is sent to |
GET PATCH DELETE | /connections/:id | fetch; rename, enable/disable; remove |
POST | /connections/:id/credential | seal its credential (write-only; no route reads one back, though a vault export includes it) |
POST | /connections/:id/capabilities | toggle one capability on or off |
POST | /connections/:id/capability-posture | from gateway 1.3.56, set one capability's risk and requires_approval. Lowering either is held (202) for a paired device like any other loosening change, and a re-sync keeps the setting while the source still derives the posture it replaced; only a capability with a pinned HTTP shape takes one. Through permaura.com only the workspace owner may send it |
POST | /connections/from-preset | create from a catalog preset; optional base_url / static_headers pin an account-specific host or tenant header, and a WordPress preset takes site_url and discovers its REST root |
POST | /connections/:id/oauth/start | begin the bring-your-own-app OAuth flow; returns the provider authorize_url |
POST | /connections/discover | read what a URL serves (a WordPress REST index and its namespaces) without creating anything |
POST | /connections/import-wordpress | derive one from a WordPress site's REST index for the chosen namespaces; dry_run returns the review, and a create must send the review's digest (from gateway 1.3.56 a create without one is a 400, before the site is read) and refuses with 409 when it no longer matches |
POST | /connections/import-openapi | derive one from an OpenAPI document, pasted as spec or fetched from url / spec_url on the bound host; dry_run returns the review and its digest. A create from url or spec_url must send that digest (from gateway 1.3.56 a create without one is a 400, before the document is fetched) and refuses with 409 when the document has changed; a pasted spec is the document itself and needs none |
POST | /connections/:id/resync | read a discovered connection's source again and return the diff; without dry_run, new and changed capabilities land switched off and removed ones are kept |
GET | /presets | the catalog presets this gateway carries |
GET POST DELETE | /connectors/installed, /connectors/install, /connectors/:id | runtime-installed connector manifests. From gateway 1.3.46 an install or removal can be held (202) for a paired device like any other loosening change, and an install is refused (403) from off a self-run gateway's machine while no device is paired or the approval floor is lowered, and on a hosted worker while no device is paired |
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 enrollment code (ENRL- and eight groups of four from gateway 1.3.56, four from 1.3.22; 15 minutes, single use). From gateway 1.3.46 a remote request (the console, the app, a permaura.com token, a forwarded call, any hosted request) gets the same code prefixed pgc_, and the agent session that code opens is remote on every transport; a request at the gateway's own machine still gets ENRL-. Sent as the bearer on /mcp, on any gateway including hosted, it opens a Pending agent session whose token is the code itself |
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 |
Two-person progress on an approval
Every approval body, from GET /v1/approvals, GET /v1/approvals/:id and POST /v1/approvals, carries how far its quorum has got:
| Field | Meaning |
|---|---|
required_signatures | How many people the action needs: 1, or the policy's device_signed_quorum |
collected_signatures | From gateway 1.3.44, how many distinct people have signed, counted as the quorum counts them: only keys still trusted, each person once, and keys with no person as one only when no key with a person has signed. Before 1.3.44 it counted the signatures recorded |
people_signed | The same count under its own name, new in 1.3.44 |
signatures | One entry per device that signed, with approver, key_fp and decided_at, so one person's iPhone and Watch are two entries and one person |
A device signature that leaves the quorum short answers 202 with awaiting_signatures: true and these fields. When device signatures approve an action, the audit record of that resolution carries people_signed in its summary.
Secrets
| Method | Path | Does |
|---|---|---|
GET POST | /secrets | list ids and metadata only; create |
POST | /secrets/:id/rotate | replace a value |
DELETE | /secrets/:id | remove |
No route returns one secret's value, by design. Listing gives you the inventory; reading a single value back is not part of the API. The exception is POST /vault/export, which writes every credential into one bundle sealed under the passphrase its caller sends, and waits for a paired device to confirm it while one is paired and the approval floor is not lowered. A held export answers 202 with an approval_id and a resume_secret; once the device has approved it, send the same request again with approval_id and resume_secret added to collect the bundle. Only the caller holding the resume_secret can collect it, and the approval lapses ten minutes after the export was asked for. On a self-run gateway, an export from off its own machine is refused (403) instead while no device is paired or, from gateway 1.3.46, while the approval floor is lowered. Through permaura.com only someone holding Export the vault can call it (Roles & permissions).
Devices and approver keys
| Method | Path | Does |
|---|---|---|
GET | /devices | every device that can sign approvals here, plus every device registered only for notifications; see below |
POST | /devices/enroll | register a device's name and signing identity; records its public key without trusting it |
DELETE | /devices/:id | revoke one and untrust its signing key. :id is a device id, or the approver id for a row with no registration; needs an approver-revoke ticket bound to the row's approver_id on hosted, and on self-run from anywhere but loopback |
POST | /devices/push-token | legacy push registration; a no-op on a gateway that announces to permaura.com |
GET | /approver-keys | the keyring's own view: every approver ever trusted, its key fingerprint, and whether it is still usable |
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; needs an approver-revoke ticket on hosted, and on self-run from anywhere but loopback |
PUT | /approval-floor | set the minimum approval tier with {"floor": "approve"}, "device_signed" or "default", which clears the stored floor so pairing and policy decide (lowering, default included, is loopback-only, same hosted exception) |
POST | /recovery-codes | issue a fresh set of a self-run gateway's recovery codes, returned once. Needs one of the current codes in X-Permaura-Recovery-Code, or a signature from a trusted device (approver_id, issued_at, sig); from gateway 1.3.46, from off the gateway's own machine, a recovery-codes-issue ticket as well, and without one the answer is 401 with the step-up challenge. 400 while no device is trusted; 403 on a hosted worker |
What GET /v1/devices returns
From gateway 1.3.24 it is the complete list of what can sign. The keyring is the spine of it: one row per approver the gateway has trusted, joined to the device registry for a name and platform where that approver also registered. Rows follow for devices that registered but were never paired, so you can see a phone the gateway knows about and see that it cannot sign.
Before 1.3.24 the list was the registry alone. A phone that declined notifications, a simulator or an Apple Watch could be trusted to sign and still be missing from it, which is the list a console offers revocation from.
{
"devices": [
{
"id": "device_k3m9x2",
"name": "iPhone",
"platform": "ios",
"approver_id": "device:a1b2c3",
"key_fp": "9c2e4a17b3d05f81",
"has_push_token": false,
"has_signing_key": true,
"is_trusted": true,
"enrolled_at": 1790050000000,
"revoked": false
},
{
"id": "device:w7q4e1",
"name": null,
"platform": null,
"approver_id": "device:w7q4e1",
"key_fp": "4f0d9b2c61a8e735",
"has_push_token": false,
"has_signing_key": true,
"is_trusted": true,
"enrolled_at": 0,
"revoked": false
}
],
"approval_floor": "approve",
"signed_approvals_required": false,
"approval_floor_override": null,
"approvals_opted_down": false
}
The second row is a key with no registration behind it, a Watch for instance. It has no name or platform, and its id is its approver id, which DELETE /v1/devices/:id accepts: revoking it revokes the key, under the same approver-revoke ticket rule as DELETE /v1/approver-keys/:approver_id.
| Field | Meaning |
|---|---|
is_trusted | The keyring's answer: this key can sign approvals now. false for a revoked key, which stays listed, and on a row that was only registered |
has_signing_key | The row carries a public key, trusted or not |
key_fp | Fingerprint of the trusted key; null on a registered-only row |
has_push_token | The gateway holds a push address for it. Always false on a gateway that announces to permaura.com, which keeps no address because permaura.com is the sender |
approval_floor | approve or device_signed, the gateway's current floor; signed_approvals_required is the same thing as a boolean |
approval_floor_override | From gateway 1.3.46, the floor as stored: approve when it was lowered on purpose, device_signed when it was raised, null when nobody set it and pairing and policy decide. approval_floor reads approve for both an untouched gateway and a lowered one |
approvals_opted_down | From gateway 1.3.46, true when the stored floor is approve. Only that switches off the device holds on a vault export and the other credential-reaching requests, which a self-run gateway then refuses from off its own machine |
GET /v1/approver-keys stays as the keyring's own view: keys[] with approver_id, key_fp, trusted, revoked, and the matching device_id and name where a registration exists (device_id: null otherwise). Use it when you want trust state and nothing else; use /devices when you want one list to show a person. It carries the same approval_floor_override and approvals_opted_down as /devices. From gateway 1.3.44 each key also carries who it approves for, and the response says how many people the keys can put behind one approval:
| Field | Meaning |
|---|---|
keys[].person_bound | The key is trusted and recorded against a person. false for a key with no person, which never counts as a second person, and for a revoked key |
keys[].person_handle | An opaque 16-character hex handle for that person: equal handles are the same person, and no account is named. null wherever person_bound is false |
reachable_people | The highest device_signed_quorum the trusted keys can meet today, counted as a quorum counts signatures: each person once, and keys with no person as one person only when no trusted key has one. 0 when nothing is trusted. A higher quorum holds its action until more people pair |
Broker controls
| Method | Path | Does |
|---|---|---|
GET | /broker | paused state, the new-agent approval flag (require_agent_approval) and where it comes from (require_agent_approval_source: default, stored or environment) |
POST | /broker/pause | global stop: every call refused while paused |
POST | /broker/require-approval | hold every newly connected agent as Pending; on by default once a device is paired on a hosted gateway or one with a public URL; turning it off from off the host waits for the paired device |
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() { printf 'authorization: Bearer %s\n' "$PERMAURA_TOKEN"; } # the header, never an argument
# 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' \
--data-binary @<(printf '{"value":"%s"}' "$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.