Skip to main content

Relay API Reference

The relay exposes an HTTP/JSON API consumed by mobile clients, the CLI, and peer relays. All endpoints consume and produce application/json. All requests and responses use UTF-8 encoding.

Base URL: https://<relay-host>

Endpoint classification​

The relay exposes two distinct surfaces and authenticates them differently:

  • Open / messaging — anyone may call. Authentication is per-message: every envelope (message or ack) is signature-verified before storage. Per-sender and per-relay rate limits run before signature verification to keep the cheap-rejection path fast.
  • Owner-only / admin — must prove ownership of the identity in question via either a challenge–response (already-implemented for GET /messages/:identity) or an identity-signed admin envelope (issued_at, nonce, identity_signature). The DNS-token check on POST /identities is necessary but no longer sufficient on its own.

Signed requests (one round trip)​

Every endpoint that accepts a signed challenge also accepts a signed request, which needs no GET /auth/challenge first. The client signs, with the identity key or a session key:

poweur-request/v1\n<identity>\n<METHOD>\n<path and query as sent>\n<unix seconds>\n<nonce>\n<hex SHA-256 of the body>

and sends X-Poweur-Identity, X-Poweur-Timestamp, X-Poweur-Nonce (16–64 characters, random), X-Poweur-Signature (base64) and, for a session key, X-Poweur-Session-Id. The relay accepts it within 60 seconds of its own clock and refuses a nonce it has already seen in that window, so a captured request can be neither altered nor replayed on that relay. Clients learn the relay's clock from the Date response header (exposed to browsers) and re-sign once on a 401 if theirs is off. Bodies of signed requests are limited to 32 MiB. The challenge flow keeps working; the keystore fetch, signed by a passkey, still uses it.

EndpointClassNotes
POST /messagesopen / messagingSubject to the at-least-one-local rule (see below)
POST /acksopen / messagingSame rule and rate limits as /messages
GET /public readService banner; JSON when Accept: application/json
GET /healthpublic readGlobal rate limit only
GET /identities/:identitypublic readGlobal rate limit only
GET /auth/challengepublic readIssues short-lived owner-only auth material
GET /messages/:identityowner-only / adminChallenge–response authenticated
POST /sessionsowner-only / adminIdentity-signed
DELETE /sessions/:idowner-only / adminIdentity-signed
POST /identitiesowner-only / adminDNS-token (self-hosted) or invite (hosted) + identity-signed
POST /identities/:identity/exportowner-onlyidentity-signed export envelope → application/gzip
POST /identities/:identity/rotateowner-onlyold-key rotation signature + new signed document
PUT /identities/:identity/keystoreowner-onlyidentity-signed enrollment
POST /identities/:identity/keystore/fetchauthenticator-onlyWebAuthn assertion — the one endpoint that does not require the identity key
DELETE /identities/:identity/keystore/:enrollmentowner-onlyidentity-signed removal
POST /identities/:identity/keystore/listowner-onlyidentity-signed; metadata only
POST /identities/:identity/enroll/offeropennew device has no key yet; capped per identity
POST /identities/:identity/enroll/:rendezvous/fetchowner-onlyidentity-signed
POST /identities/:identity/enroll/:rendezvous/deliverowner-onlyidentity-signed
POST /identities/:identity/enroll/:rendezvous/revealbearerthe offer's claim token
GET/DELETE /identities/:identity/enroll/:rendezvousbearerthe offer's claim token; releases only ciphertext
GET/PUT/DELETE /identities/:identity/system/:pathowner-onlychallenge-signed; .poweur/{public,relay} documents, validated on write
/drive/:identity/…owner or memberchallenge-signed; see Drive API

At-least-one-local rule​

Both POST /messages and POST /acks enforce a single forwarding rule that prevents the relay from being abused as an open forwarder for the world:

senderLocal    = identityStore.Exists(envelope.sender)    || dns(envelope.sender)    points here
recipientLocal = identityStore.Exists(envelope.recipient) || dns(envelope.recipient) points here
accept iff senderLocal || recipientLocal

Three accepted cases follow:

  1. Recipient-local (normal inbound): store in the local inbox.
  2. Sender-local, recipient-remote (the only sanctioned forward, used by the --via-home-relay privacy proxy): forward over HTTP to the recipient relay (DNS-resolved).
  3. Both local (note-to-self): store in the local inbox.

Anything else returns 403 not_authorized before signature verification, so the cheap reject path stays cheap.


POST /messages​

Submit a signed message for delivery. The relay verifies the sender's signature, applies the at-least-one-local rule, and then either stores the message for a local recipient or forwards it to the recipient's relay over HTTP.

By default, clients post directly to the recipient's relay, resolved via DNS (A / CNAME for <recipient> or HOST:<recipient> in the fake-DNS test environment). The sender's home relay never sees the outbound traffic. Clients that want to hide their IP from the recipient's relay opt into the home-relay proxy mode (CLI flag --via-home-relay), which posts to the sender's home relay; the home relay accepts because the sender is local and forwards over HTTP to the recipient relay.

Rate limiting​

Rate limit checks run before signature verification. Both per-sender and global per-relay buckets are evaluated; whichever fires first produces the 429. See Rate Limiting for the default thresholds.

Request body​

{
"id": "msg_01j9xkay7g000000000000000",
"sender": "alice.poweur.net",
"recipient": "bob.example.org",
"timestamp": "2026-03-28T12:00:00Z",
"payload": "<base64url AEAD ciphertext>",
"signature": "<base64 Ed25519 signature over canonical fields>",
"session_id": "sess_01j...",
"session_proof": {
"session_public_key": "<base64url>",
"issued_at": "...",
"expires_at": "...",
"nonce": "...",
"identity_signature": "<base64>"
},
"encryption": {
"alg": "x25519-chacha20-poly1305",
"ephemeral_public_key": "<base64url>",
"nonce": "<base64url>"
}
}

id, sender, recipient, timestamp, payload, signature, and encryption are always required. The client-assigned id is bound into the canonical signing string and used as the inbox storage key on the recipient relay. session_id and session_proof are optional — MVP clients include session_id on every routine (session-signed) send. Envelopes without encryption.alg, encryption.ephemeral_public_key, or encryption.nonce are rejected with 400 encryption_required before signature verification or rate-limiting runs. See Message Format for the canonical signing string and the signature verification rules.

Responses​

StatusMeaning
202 AcceptedMessage accepted for delivery or forwarding (corresponds to delivery tick 1 — see Delivery Acks)
400 Bad RequestMalformed message envelope (missing field, invalid JSON, invalid timestamp format). encryption_required when the envelope is missing the encryption block.
401 UnauthorizedSignature verification failed, or session_expired when session_id is unknown and no valid session_proof is attached
403 Forbiddennot_authorized — neither sender nor recipient is locally hosted on this relay (see at-least-one-local rule)
413 Content Too LargeRequest body exceeds 512 KB
429 Too Many RequestsSender or relay exceeded a rate-limit bucket. The scope field in the body is sender or global.

When the relay returns 401 session_expired, the client should silently register a new session via POST /sessions and retry the send.

429 response body:

{
"error": "rate_limit_exceeded",
"scope": "sender",
"window": "minute",
"limit": 20,
"reset_at": "2026-03-28T12:01:00Z"
}

The 202 Accepted body echoes the client-assigned id so the sender can correlate the response with their pending journal entry:

{
"id": "msg_01j9xkay7g000000000000000"
}

POST /acks​

Submit a signed delivery acknowledgement. Open / messaging-class endpoint that shares signature verification, rate limits, and the at-least-one- local rule with POST /messages. In v1 the only valid state is delivered_client, recorded by a recipient client immediately after a successful decrypt.

Request body​

{
"type": "ack",
"id": "ack_01j...",
"message_id": "msg_01j...",
"state": "delivered_client",
"sender": "bob.example.org",
"recipient": "alice.poweur.net",
"timestamp": "2026-03-28T12:04:05Z",
"signature": "<base64 Ed25519 signature over canonical fields>",
"session_id": "sess_...",
"session_proof": { ... }
}

sender is the party that produced the ack (the recipient of the original message). recipient is the party that cares whether tick 2 ever arrives (the original message's sender). See Delivery Acks for the canonical signing string and the journal semantics.

Responses​

StatusMeaning
202 AcceptedAck accepted; will be drained on the next GET /messages/:identity for the recipient
400 Bad RequestMalformed ack envelope or unsupported state value
401 UnauthorizedSignature verification failed
403 Forbiddennot_authorized — neither party is locally hosted (see at-least-one-local rule)
413 Content Too LargeRequest body too large
429 Too Many RequestsSender or relay exceeded a rate-limit bucket

GET /messages/:identity​

Retrieve pending messages for a locally hosted identity. The requester must prove ownership via a challenge–response flow before messages are returned.

Authentication​

Before calling this endpoint, obtain a challenge from GET /auth/challenge?identity=<identity>. Sign the challenge and include the signature and identity in request headers.

HeaderRequiredValue
X-Poweur-IdentityYesThe identity subdomain (e.g. alice.poweur.net)
X-Poweur-SignatureYesBase64-encoded signature of the challenge string
X-Poweur-Session-IdNoSession identifier when the challenge is signed with the session key. If omitted, the relay verifies with the long-lived identity key.

When X-Poweur-Session-Id is present but the session is unknown or expired, the relay responds with 401 session_expired so the client can re-register and retry.

Path parameters​

ParameterDescription
:identityFully qualified identity subdomain (alice.poweur.net)

Response body​

{
"messages": [
{
"id": "msg_01j9xk7q2f000000000000000",
"sender": "bob.example.org",
"recipient": "alice.poweur.net",
"timestamp": "2026-03-28T12:00:00Z",
"payload": "<base64url AEAD ciphertext>",
"signature": "<base64-encoded signature>",
"session_id": "sess_01j...",
"encryption": {
"alg": "x25519-chacha20-poly1305",
"ephemeral_public_key": "<base64url>",
"nonce": "<base64url>"
}
}
],
"acks": [
{
"type": "ack",
"id": "ack_01j...",
"message_id": "msg_01j...",
"state": "delivered_client",
"sender": "bob.example.org",
"recipient": "alice.poweur.net",
"timestamp": "2026-03-28T12:04:05Z",
"signature": "<base64>"
}
]
}

The relay forwards each envelope as it was signed. Clients should verify the signature and, when encryption is set, decrypt with the recipient's local X25519 private key before displaying the payload.

The acks array carries delivery acknowledgements for messages this identity previously sent (tick 2 in the WhatsApp-style two-tick model — see Delivery Acks). Both arrays are drained on read, so a polling client gets exactly-one delivery of every pending event in a single round-trip. If there is nothing pending, the corresponding array is [].

Responses​

StatusMeaning
200 OKSuccess — array of pending messages (may be empty)
401 UnauthorizedChallenge signature invalid, expired, or headers missing
404 Not FoundIdentity is not hosted on this relay

GET /auth/challenge​

Issue a short-lived challenge string for use in authenticated requests (specifically GET /messages/:identity).

Query parameters​

ParameterRequiredDescription
identityYesThe identity subdomain requesting a challenge

Example request​

GET /auth/challenge?identity=alice.poweur.net

Response body​

{
"challenge": "eyJhbGciOiJub25lIn0.eyJpZGVudGl0eSI6ImFsaWNlLnBvd2V1ci5uZXQiLCJleHAiOjE3NDMxNjQ3MDB9.",
"expires_at": "2026-03-28T12:05:00Z"
}

Challenges expire after 60 seconds and are invalidated after first use. The relay stores issued challenges in memory; they are cleared on expiry or use, whichever comes first.

Responses​

StatusMeaning
200 OKChallenge issued
400 Bad RequestMissing or invalid identity parameter

POST /identities​

Register a new identity on this relay. Two modes:

Hosted registration (no DNS writes)​

Omit dns_provider / dns_token. The identity must be under a domain listed in HOSTED_DOMAINS. Supply a signed identity_document (see Web Identity). A single wildcard DNS A/CNAME for the hosted domain routes all identities; the relay persists the document under POWEUR_DATA and serves it at /.well-known/poweur/id.json.

DNS registration (self-hosted)​

Include dns_provider + dns_token. The relay writes zone records as before. When POWEUR_DATA is set, a signed identity_document is also required so the relay can serve well-known endpoints.

Owner-only: the request always includes an identity-signed admin envelope (issued_at, nonce, identity_signature) so a DNS-token holder cannot register a public key they do not control.

Request body​

{
"identity": "alice.poweur.net",
"public_key": "<base64url-encoded Ed25519 public key>",
"encryption_public_key": "<base64url-encoded X25519 public key>",
"dns_provider": "cloudflare",
"dns_token": "<scoped DNS provider API token>",
"identity_document": { "version": 1, "identity": "...", "signature": "..." },
"invite_code": "<optional; required when REGISTRATION_GATE=invite>",
"issued_at": "2026-03-28T12:00:00Z",
"nonce": "<base64url random nonce>",
"identity_signature": "<base64 signature of canonical identity-registration string>"
}
FieldTypeDescription
identitystringFully qualified identity subdomain to register
public_keystringBase64url-encoded Ed25519 identity public key (no padding)
encryption_public_keystringBase64url-encoded X25519 encryption public key (no padding). Optional in the API but written for every identity by CLI and mobile clients in the MVP.
dns_providerstringDNS provider — cloudflare or hetzner. Omit for hosted registration.
dns_tokenstringScoped API token for the target DNS zone. Omit for hosted registration.
identity_documentobjectSigned Identity Document (required for hosted; required when POWEUR_DATA is set)
issued_atstringRFC3339 UTC timestamp; relay enforces a recency window
noncestringPer-request nonce; included in the canonical string to bind the signature to this exact request
identity_signaturestringBase64-encoded Ed25519 signature over the canonical identity-registration string, verified against the public_key in this body

Also: GET /.well-known/poweur/id.json (Host-routed) serves the stored document.

GET /.well-known/poweur/capabilities.json serves the identity's own .poweur/public/capabilities.json, with two endpoints filled in where the file does not set them: endpoints.web_signer (<scheme>://<identity>/app/, when WEB_STATIC_DIR is set) and endpoints.oauth_bridge (when OAUTH_BRIDGE_URL is set). With no file, those defaults alone are served. When OAUTH_BRIDGE_URL is set, GET / on a hosted identity's host also carries Link: <bridge>/.well-known/oauth-authorization-server; rel="indieauth-metadata", which is how IndieAuth clients discover who signs that identity in (EPIC-022).

The canonical identity-registration string is:

identity-registration
<identity>
<public_key>
<encryption_public_key>
<relay_address>
<issued_at>
<nonce>

<relay_address> is the host[:port] of the relay the request is being made against (the relay's RelayAddress config value). <encryption_public_key> is the empty string when not supplied.

DNS records written​

On success, the relay creates (or updates) up to three DNS records:

  1. TXT at _poweur.<identity> — poweur-pubkey=ed25519:<public_key>
  2. TXT at _poweur-enc.<identity> — poweur-enckey=x25519:<encryption_public_key> (only when encryption_public_key is supplied)
  3. A (or CNAME) at <identity> — pointing to the relay's own address

Responses​

StatusMeaning
201 CreatedIdentity registered and DNS records written
400 Bad RequestMalformed request, missing fields (including a missing admin envelope), or unsupported dns_provider value
401 Unauthorizedidentity_signature failed to verify against the body's public_key
409 ConflictIdentity is already registered on this relay
502 Bad GatewayDNS provider write failed (token invalid, zone not found, etc.)

201 response body:

{
"identity": "alice.poweur.net",
"public_key": "<base64url-encoded identity public key>",
"encryption_public_key": "<base64url-encoded encryption public key>",
"relay": "relay.poweur.net",
"created_at": "2026-03-28T12:00:00Z"
}

502 response body:

{
"error": "dns_write_failed",
"detail": "Cloudflare API returned 403: token lacks zone:edit permission"
}

Cursor pickup (EPIC-009)​

GET /messages/:identity?since=<cursor> reads without forgetting and returns a cursor alongside the messages. The client acknowledges what it has with:

POST /messages/:identity/consume
{ "through": "<cursor>", "ack_through": "<cursor>" }

Only then does the relay drop them. Splitting the read from the acknowledgement is the point: a pickup that deletes as it serves loses messages to a dropped connection. Calling GET /messages/:identity without since keeps the original drain-on-read behaviour, so existing clients are unaffected.

Undelivered mail is spooled durably under POWEUR_DATA and survives a relay restart. It is retired after SPOOL_TTL (default 30 days), and the sender receives a sys.delivery.failed ack — generated by the relay and therefore unsigned, since only the relay can say it gave up holding something. Clients must read it as the relay's own admission, not as proof about the recipient.


GET /events/:identity​

A push stream, authenticated with the same challenge-signed headers as the inbox pickup. Server-Sent Events over a streamed response — read it with fetch, not EventSource, which cannot send headers.

event: ready
data: {"type":"ready","identity":"alice.poweur.net","timestamp":"…"}

event: message
data: {"type":"message","identity":"alice.poweur.net","message_id":"msg_…","timestamp":"…"}

Events carry no payload. They say that something arrived; the cursor read they trigger is where delivery happens. A dropped frame therefore costs a round trip rather than a message, which is why reconnecting needs no replay protocol — and why polling remains a complete fallback. : keepalive comment frames arrive every 25 s.

StatusMeaning
200 OKStream open
401 UnauthorizedChallenge missing, expired, or badly signed
429 Too Many RequestsMAX_STREAMS_PER_IDENTITY reached

GET /hosted/availability​

Is this handle claimable, and if not, why? Read-only, unauthenticated, and meant to be called before a client spends a WebAuthn ceremony on a name the relay will refuse.

Query parameters​

ParameterRequiredDescription
handleyesThe leftmost label only (melissa, not melissa.poweur.net)
domainnoHosted parent; defaults to the relay's first HOSTED_DOMAINS entry

Response body​

Always 200 with a verdict — the failure modes are answers, not errors:

{
"handle": "admin",
"identity": "admin.poweur.net",
"available": false,
"reason": "reserved",
"message": "This name is reserved by the operator.",
"policy": { "min_len": 6, "max_len": 24, "charset": "a-z 0-9 -" }
}

reason is one of available, taken, reserved, blocked, too_short, too_long, charset, hyphen, punycode, domain_not_hosted. policy echoes the relay's handle policy so a client can validate inline without hardcoding the rules of the relay it is talking to.

Registration is checked first. A name that exists answers taken, including a reserved or short one the operator created, so its own page offers sign-in. That reveals nothing new: a registered ID's document is public. A name nobody holds reports why the policy refuses it (reserved, too_short, …).

StatusMeaning
200 OKVerdict returned (including every "not available" case)
400 Bad Requesthandle missing
429 Too Many RequestsPer-IP limit. This endpoint enumerates the registered set — a public registry is enumerable by design, so the limit is about cost, not secrecy, and it is charged several units per call against the same per-IP bucket messages use

GET /identities/:identity​

Look up the public key registered for an identity on this relay. Used by peer relays to fetch a sender's public key when a DNS TXT record lookup is unavailable or not yet propagated.

Path parameters​

ParameterDescription
:identityFully qualified identity subdomain

Response body​

{
"identity": "alice.poweur.net",
"public_key": "<base64url-encoded Ed25519 public key>"
}

Responses​

StatusMeaning
200 OKIdentity found, public key returned
404 Not FoundIdentity is not hosted on this relay

POST /sessions​

Register a short-lived session key with the relay. The client generates a fresh Ed25519 keypair, signs a canonical session-registration string with the long-lived identity key, and submits it. The relay verifies the identity signature against the identity's DNS-published public key, enforces the 24-hour maximum TTL, issues a session_id, and caches the session in memory.

Request body​

{
"identity": "alice.poweur.net",
"session_public_key": "<base64url Ed25519 session public key>",
"issued_at": "2026-03-28T08:00:00Z",
"expires_at": "2026-03-29T08:00:00Z",
"nonce": "<base64url random nonce>",
"identity_signature": "<base64 signature of canonical session-registration string>",
"device_fingerprint": "optional opaque device id"
}

The canonical session-registration string is:

session-registration
<identity>
<session_public_key>
<issued_at>
<expires_at>
<nonce>

signed with the long-lived identity key (Ed25519). See Identity Model → Session & Passkey Flow.

Responses​

StatusMeaning
201 CreatedSession registered
400 Bad RequestMissing/invalid fields, TTL over 24h, issued_at too far in the future, malformed timestamps
401 Unauthorizedidentity_signature failed to verify against the identity's long-lived public key

201 response body:

{
"session_id": "sess_01j9xk7q...",
"identity": "alice.poweur.net",
"session_public_key": "<base64url session public key>",
"issued_at": "2026-03-28T08:00:00Z",
"expires_at": "2026-03-29T08:00:00Z"
}

Relay restarts invalidate all sessions. Clients should treat 401 session_expired on subsequent calls as a signal to re-register.


DELETE /sessions/:id​

Revoke a session. Owner-only / admin endpoint: the request body must carry an identity-signed admin envelope so an attacker who guesses a session id cannot invalidate someone else's sessions. Idempotent — deleting a session that does not exist still returns 204, but only after authentication succeeds.

Path parameters​

ParameterDescription
:idThe session id returned by POST /sessions

Request body​

{
"identity": "alice.poweur.net",
"issued_at": "2026-03-28T12:00:00Z",
"nonce": "<base64url random nonce>",
"identity_signature": "<base64 signature of canonical session-revocation string>"
}

The canonical session-revocation string is:

session-revocation
<identity>
<session_id>
<issued_at>
<nonce>

The relay verifies the signature against the long-lived signing key of the claimed identity (resolved from the local identity cache, then DNS on miss). If the session is known and its owning identity does not match the claimed identity, the relay rejects with 401 unauthorized.

Responses​

StatusMeaning
204 No ContentSession removed (or already absent)
400 Bad RequestMissing id or missing admin envelope
401 Unauthorizedidentity_signature failed to verify, or session belongs to a different identity

POST /identities/:identity/export​

Owner-signed export of the identity document and its .poweur system files as application/gzip (tar.gz), with paths such as .poweur/public/id.json. Encrypted drive content is exported by clients, which hold the keys. Canonical string: identity-export\n<identity>\n<issued_at>\n<nonce>.

POST /identities/:identity/rotate​

Rotate the long-lived signing key. Body includes identity_document (signed by the new key, with previous_keys), new_public_key, and rotation_signature from the old key over identity-rotation\n…. See Web identity — Key rotation.

GET /​

Service banner. Browser navigations on a launcher host may 302 to /app/; clients that send Accept: application/json always get this document. The release fields are how Settings → About and poweur --version consumers learn what is running (EPIC-013 E13-T6).

Response body​

{
"service": "poweur-relay",
"relay_address": "poweur.net",
"launcher_host": "id.poweur.net",
"launcher_hosts": ["id.poweur.net", "poweur.net"],
"hosted_domains": ["poweur.net"],
"web_ui": "GET /app/ (when WEB_STATIC_DIR is set)",
"version": "0.1.1",
"buildTime": "2026-09-10 12:00",
"versionHash": "0434c17…"
}

version is the relay semver. buildTime is UTC YYYY-MM-DD HH:MM when known. versionHash is the git revision the binary was built from.


GET /health​

Liveness check. Used by load balancers, monitoring systems, and client connectivity checks. When POWEUR_DATA is set, includes storage health; status may be degraded if not writable.

Response body​

{
"status": "ok",
"version": "0.1.1",
"buildTime": "2026-09-10 12:00",
"versionHash": "0434c17…",
"storage": {
"configured": true,
"path": "/data",
"writable": true,
"free_bytes": 123456789
}
}

Responses​

StatusMeaning
200 OKRelay is healthy (or degraded but still serving)

Keystore (EPIC-011)​

Wrapped copies of an identity's master seed, one per enrolled authenticator. The relay stores ciphertext it cannot open — wrapping secrets never leave the authenticator or device. See Key management & recovery.

Writes are authenticated by the identity key; the read is authenticated by a WebAuthn assertion instead. That asymmetry is deliberate: the read exists to recover an identity whose key you no longer hold, so requiring that key would be circular.

The wrapped blobs deliberately live outside the owner system-file API. They need a read path the identity key cannot provide, and a blob inside the user's file tree would be one misplaced delete away from destroying their recovery.

PUT /identities/:identity/keystore​

Store or replace one enrollment. Signed with keystore-enroll:

keystore-enroll\n<identity>\n<enrollment_id>\n<kind>\n<credential_id>\n<wrapped_digest>\n<issued_at>\n<nonce>

wrapped_digest is the base64url SHA-256 of the raw wrapped JSON as sent. It binds the signature to the exact ciphertext, so a swapped blob under an otherwise valid authorization is rejected.

{
"enrollment_id": "enr-001",
"kind": "passkey",
"wrap": "prf",
"credential_id": "<base64url>",
"credential_public_key": "<SPKI DER, base64url>",
"credential_alg": -8,
"wrapped": { "iv": "...", "ciphertext": "..." },
"label": "Laptop",
"role": "device",
"issued_at": "2026-01-15T09:30:00Z",
"nonce": "...",
"identity_signature": "..."
}
FieldValues
kindpasskey | hardware-key | cli-passphrase | recovery-kit | native
wrapprf | passphrase | native
credential_algCOSE id: -7 ES256, -8 EdDSA, -257 RS256
roledevice (default) | recovery-master

credential_public_key is SPKI DER, exactly what WebAuthn's getPublicKey() returns — not raw COSE. This keeps assertion verification inside the standard library rather than adding a CBOR/COSE parser to the trusted path.

credential_id and credential_public_key must be supplied together: a passkey enrollment the relay cannot verify could never satisfy the bootstrap read, so it is rejected rather than stored as a dead entry.

Responses: 200 with {enrollment_id, created_at}; 400 invalid fields; 401 bad signature; 404 unknown identity.

POST /identities/:identity/keystore/fetch​

The bootstrap read. Obtain a challenge from GET /auth/challenge?identity=..., sign it with an enrolled authenticator, and post the assertion:

{
"assertion": {
"credential_id": "<base64url>",
"client_data_json": "<base64url>",
"authenticator_data": "<base64url>",
"signature": "<base64url>"
},
"rp_id": "poweur.net"
}

Verified: clientDataJSON.type is webauthn.get; the challenge matches the relay-issued one; rpIdHash matches an acceptable relying-party id; the user-present and user-verified flags are set; and the signature verifies over authenticatorData || SHA-256(clientDataJSON).

Acceptable rp_id values are the identity itself or its registrable domain when hosted — a credential may legitimately be scoped to poweur.net while the request arrives at alice.poweur.net (see EPIC-018 E18-T4).

Responses: 200 with {identity, entries[]} (ciphertext only); 401 for anything rejected; 404 unknown identity.

note

The challenge is single-use and consumed on every attempt, so a captured assertion cannot be replayed. An unenrolled credential id and a bad signature return byte-identical 401 responses: the caller must not learn which credentials are enrolled. Combined with discoverable credentials (the web client already sets residentKey: "required"), the relay never reveals credential ids to an unverified caller.

POST /identities/:identity/keystore/list​

Enumerate enrollments for the owner — the "Keys & devices" inventory. Signed with keystore-list\n<identity>\n<issued_at>\n<nonce>.

Returns metadata only: enrollment_id, kind, wrap, label, role, has_passkey, created_at, last_used_at. Listing your devices needs no access to the wrapped seed copies, so the ciphertext is not in the response at all.

DELETE /identities/:identity/keystore/:enrollment​

Remove an enrollment. Signed with keystore-remove:

keystore-remove\n<identity>\n<enrollment_id>\n<issued_at>\n<nonce>

Responses: 204; 401 bad signature; 404 unknown identity or enrollment.

Optional fields:

FieldEffect
actor_assertionWebAuthn assertion proving the caller holds a recovery-master authenticator
rp_idRelying party the actor assertion was scoped to
allow_lastPermit removing the final enrollment (refused by default)
revoke_sessionsAlso end the removed device's live sessions

Recovery-master gating. Once an identity has a recovery-master enrollment, every removal must carry actor_assertion from it. This is what makes the role enforceable rather than advisory: the identity key is shared by every device, so an identity signature alone says nothing about which device is asking — and without the extra proof a stolen phone could evict the very security key meant to revoke it.

Removing the last enrollment returns 409 last_enrollment unless allow_last is set. Silently stranding recovery is worse than an error the caller has to acknowledge.

Responses: 204, or 200 with {enrollment_id, sessions_revoked} when sessions were revoked; 401 bad signature; 403 recovery-master required; 404; 409 last enrollment.

caution

Removal denies that authenticator the bootstrap read. It does not protect against an attacker who already extracted the seed — that is what rotation is for.


Device registry​

GET /devices/:identity (owner-authenticated) lists the devices the relay has seen for an identity, and POST /devices/:identity/revoke with { "device_id": "dev_…" } ends a device's sessions. Every client — web, native app, Go and TS CLI — announces itself with optional headers on the requests that open a session, and the relay records them on one row per device:

HeaderRow fieldNotes
X-Poweur-Deviceid (hashed)random per-install fingerprint; the relay keeps only dev_ + a hash
X-Poweur-Device-Namenamehostname, or "Safari on Mac", "iPhone" (≤ 64 bytes)
X-Poweur-Device-Kindkindlaptop, phone, browser, agent, …
X-Poweur-Device-Clientclientapp, web or cli; anything else is dropped
X-Poweur-Device-Platformplatform"macOS", "iOS", … (≤ 32 bytes)
X-Poweur-Device-Browserbrowserweb clients only (≤ 32 bytes)
X-Poweur-Device-Enrollmentenrollment_idthe device's keystore enrollment, when it has one

All are self-reported labels, never credentials. A later request that omits a header leaves the stored value alone. Rows also carry added_at and last_seen.

Device pairing (EPIC-011 E11-T8)​

Moving a seed to a new device needs an authentic channel, not a secret one — and the relay is trusted for neither: it must never read the seed, and a compromised relay must not be able to pair a device of its own. Pairing is commit-then-reveal; the exact values are in packages/identity/pairing.go (vectors: pairing.json). See Key management & recovery.

new device  → offer   {commitment}                 ← code, claim_token
approver → fetch {approver_nonce, mode} (identity-signed)
new device ← poll {state: "nonce"} → reveal {ephemeral_public_key, commit_nonce}
approver → fetch ← key + commit_nonce; checks the commitment
approver → deliver {sealed} (identity-signed)
new device ← poll {state: "delivered", sealed} (once)

The relay computes no digits and is never asked to: every check happens on the two devices.

Changed from v1

v1 posted the ephemeral key itself and derived six digits from it alone, so a relay could grind a key of its own with the same digits and receive the seed. A v1 offer (ephemeral_public_key without commitment) is refused with 400 pairing_v1.

POST /identities/:identity/enroll/offer​

Opened by the new device. Unauthenticated by necessity — it has no key yet — so offers are capped at 5 concurrent per identity (429 too_many_offers) and expire after 10 minutes.

{ "commitment": "<SHA-256(\"poweur/v2/enroll-commit\\n\" + key + \"\\n\" + commit_nonce), base64url>", "label": "Firefox on Linux" }

Returns 201 with {rendezvous_id, claim_token, expires_at}. rendezvous_id is the 8-character short code people type (Crockford base32; any path accepts it typed loosely — lower case, a dash, O for 0). claim_token authenticates the new device's own calls as Authorization: Bearer.

POST /identities/:identity/enroll/:rendezvous/fetch​

The approving device. Signed with enroll-fetch\n<identity>\n<code>\n<issued_at>\n<nonce>, the canonical code bound in. Body adds approver_nonce (32 random bytes, base64url) and mode (scan when the approver has the commitment from a scanned link, else compare). The first fetch records the nonce; a later fetch must repeat it (409 already_approving otherwise). Returns {state, commitment, label, expires_at} plus, once revealed, ephemeral_public_key and commit_nonce — which the approver must check open the commitment.

POST /identities/:identity/enroll/:rendezvous/reveal​

The new device opens its commitment: {ephemeral_public_key, commit_nonce}, bearer claim_token. Accepted only after the approver's nonce exists and only once (409 out_of_order).

POST /identities/:identity/enroll/:rendezvous/deliver​

The sealed seed, after the reveal. Signed with enroll-deliver\n<identity>\n<code>\n<issued_at>\n<nonce>. sealed is opaque to the relay. Once only.

GET /identities/:identity/enroll/:rendezvous​

The new device's poll, bearer claim_token: {state, approver_nonce, mode}, then {state: "delivered", sealed, ready: true} once — the pairing is consumed.

DELETE /identities/:identity/enroll/:rendezvous​

Abandon an offer (bearer claim_token), freeing its slot.


Drive API​

Each identity's end-to-end encrypted drive. Every request carries the challenge headers (X-Poweur-Identity, X-Poweur-Challenge, X-Poweur-Signature, optionally X-Poweur-Session-Id); callers from other relays authenticate the same way. The owner may do anything; members may do what their shares' roles allow on the shared nodes. The endpoints, bodies and the drive.changed event are specified in Storage v2 → HTTP surface.

EndpointPurpose
GET /drive/:identityRoot node, bytes used, quota
POST /drive/:identity/chunks/missingWhich chunks to upload, and where (relay or presigned S3 URL)
PUT /drive/:identity/chunks/:hashUpload one encrypted chunk through the relay
POST /drive/:identity/commitSigned manifest, append records or a trim; 409 with head on a stale base
GET /drive/:identity/changes?cursor=NChanges after sequence N
GET /drive/:identity/nodes/:node[/children|/history|/records]Node head, children, retained versions, append tail
GET /drive/:identity/nodes/:node/versions/:version[/pages/:page|/chunks/:hash]Signed manifest, chunk-list page, chunk
GET /drive/:identity/nodes/:node/chunks/:hashChunk of an append record
GET /drive/:identity/sharesShares the caller may see (members: their own, with sealed keys)
GET /drive/:identity/nodes/:node/sharesShares on a node and its ancestors, for readers verifying version authors
GET /drive/:identity/eventsdrive.changed SSE filtered to the caller; closes on revocation
GET /drive/:identity/links/:linkPublic: how to open a link (salt, KDF, PoW, opens left)

Link holders send X-Poweur-Link (and X-Poweur-Link-Verifier for a password) instead of the challenge headers. A member of a group identity hosted on another relay adds X-Poweur-Group-Roster (the group's signed roster, base64url); GET /groups/:group/epoch (public) returns the group's current membership epoch, 0 for non-groups.

Error Format​

All error responses use a consistent JSON envelope:

{
"error": "<error_code>",
"detail": "<human-readable explanation>"
}

The detail field is informational and should not be parsed programmatically. Use the HTTP status code and error code for error handling logic.