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 onPOST /identitiesis 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.
| Endpoint | Class | Notes |
|---|---|---|
POST /messages | open / messaging | Subject to the at-least-one-local rule (see below) |
POST /acks | open / messaging | Same rule and rate limits as /messages |
GET / | public read | Service banner; JSON when Accept: application/json |
GET /health | public read | Global rate limit only |
GET /identities/:identity | public read | Global rate limit only |
GET /auth/challenge | public read | Issues short-lived owner-only auth material |
GET /messages/:identity | owner-only / admin | Challenge–response authenticated |
POST /sessions | owner-only / admin | Identity-signed |
DELETE /sessions/:id | owner-only / admin | Identity-signed |
POST /identities | owner-only / admin | DNS-token (self-hosted) or invite (hosted) + identity-signed |
POST /identities/:identity/export | owner-only | identity-signed export envelope → application/gzip |
POST /identities/:identity/rotate | owner-only | old-key rotation signature + new signed document |
PUT /identities/:identity/keystore | owner-only | identity-signed enrollment |
POST /identities/:identity/keystore/fetch | authenticator-only | WebAuthn assertion — the one endpoint that does not require the identity key |
DELETE /identities/:identity/keystore/:enrollment | owner-only | identity-signed removal |
POST /identities/:identity/keystore/list | owner-only | identity-signed; metadata only |
POST /identities/:identity/enroll/offer | open | new device has no key yet; capped per identity |
POST /identities/:identity/enroll/:rendezvous/fetch | owner-only | identity-signed |
POST /identities/:identity/enroll/:rendezvous/deliver | owner-only | identity-signed |
POST /identities/:identity/enroll/:rendezvous/reveal | bearer | the offer's claim token |
GET/DELETE /identities/:identity/enroll/:rendezvous | bearer | the offer's claim token; releases only ciphertext |
GET/PUT/DELETE /identities/:identity/system/:path | owner-only | challenge-signed; .poweur/{public,relay} documents, validated on write |
/drive/:identity/… | owner or member | challenge-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:
- Recipient-local (normal inbound): store in the local inbox.
- Sender-local, recipient-remote (the only sanctioned forward, used
by the
--via-home-relayprivacy proxy): forward over HTTP to the recipient relay (DNS-resolved). - 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
| Status | Meaning |
|---|---|
202 Accepted | Message accepted for delivery or forwarding (corresponds to delivery tick 1 — see Delivery Acks) |
400 Bad Request | Malformed message envelope (missing field, invalid JSON, invalid timestamp format). encryption_required when the envelope is missing the encryption block. |
401 Unauthorized | Signature verification failed, or session_expired when session_id is unknown and no valid session_proof is attached |
403 Forbidden | not_authorized — neither sender nor recipient is locally hosted on this relay (see at-least-one-local rule) |
413 Content Too Large | Request body exceeds 512 KB |
429 Too Many Requests | Sender 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
| Status | Meaning |
|---|---|
202 Accepted | Ack accepted; will be drained on the next GET /messages/:identity for the recipient |
400 Bad Request | Malformed ack envelope or unsupported state value |
401 Unauthorized | Signature verification failed |
403 Forbidden | not_authorized — neither party is locally hosted (see at-least-one-local rule) |
413 Content Too Large | Request body too large |
429 Too Many Requests | Sender 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.
| Header | Required | Value |
|---|---|---|
X-Poweur-Identity | Yes | The identity subdomain (e.g. alice.poweur.net) |
X-Poweur-Signature | Yes | Base64-encoded signature of the challenge string |
X-Poweur-Session-Id | No | Session 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
| Parameter | Description |
|---|---|
:identity | Fully 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
| Status | Meaning |
|---|---|
200 OK | Success — array of pending messages (may be empty) |
401 Unauthorized | Challenge signature invalid, expired, or headers missing |
404 Not Found | Identity 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
| Parameter | Required | Description |
|---|---|---|
identity | Yes | The 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
| Status | Meaning |
|---|---|
200 OK | Challenge issued |
400 Bad Request | Missing 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>"
}
| Field | Type | Description |
|---|---|---|
identity | string | Fully qualified identity subdomain to register |
public_key | string | Base64url-encoded Ed25519 identity public key (no padding) |
encryption_public_key | string | Base64url-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_provider | string | DNS provider — cloudflare or hetzner. Omit for hosted registration. |
dns_token | string | Scoped API token for the target DNS zone. Omit for hosted registration. |
identity_document | object | Signed Identity Document (required for hosted; required when POWEUR_DATA is set) |
issued_at | string | RFC3339 UTC timestamp; relay enforces a recency window |
nonce | string | Per-request nonce; included in the canonical string to bind the signature to this exact request |
identity_signature | string | Base64-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:
TXTat_poweur.<identity>—poweur-pubkey=ed25519:<public_key>TXTat_poweur-enc.<identity>—poweur-enckey=x25519:<encryption_public_key>(only whenencryption_public_keyis supplied)A(orCNAME) at<identity>— pointing to the relay's own address
Responses
| Status | Meaning |
|---|---|
201 Created | Identity registered and DNS records written |
400 Bad Request | Malformed request, missing fields (including a missing admin envelope), or unsupported dns_provider value |
401 Unauthorized | identity_signature failed to verify against the body's public_key |
409 Conflict | Identity is already registered on this relay |
502 Bad Gateway | DNS 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.
| Status | Meaning |
|---|---|
200 OK | Stream open |
401 Unauthorized | Challenge missing, expired, or badly signed |
429 Too Many Requests | MAX_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
| Parameter | Required | Description |
|---|---|---|
handle | yes | The leftmost label only (melissa, not melissa.poweur.net) |
domain | no | Hosted 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, …).
| Status | Meaning |
|---|---|
200 OK | Verdict returned (including every "not available" case) |
400 Bad Request | handle missing |
429 Too Many Requests | Per-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
| Parameter | Description |
|---|---|
:identity | Fully qualified identity subdomain |
Response body
{
"identity": "alice.poweur.net",
"public_key": "<base64url-encoded Ed25519 public key>"
}
Responses
| Status | Meaning |
|---|---|
200 OK | Identity found, public key returned |
404 Not Found | Identity 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
| Status | Meaning |
|---|---|
201 Created | Session registered |
400 Bad Request | Missing/invalid fields, TTL over 24h, issued_at too far in the future, malformed timestamps |
401 Unauthorized | identity_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
| Parameter | Description |
|---|---|
:id | The 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
| Status | Meaning |
|---|---|
204 No Content | Session removed (or already absent) |
400 Bad Request | Missing id or missing admin envelope |
401 Unauthorized | identity_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
| Status | Meaning |
|---|---|
200 OK | Relay 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": "..."
}
| Field | Values |
|---|---|
kind | passkey | hardware-key | cli-passphrase | recovery-kit | native |
wrap | prf | passphrase | native |
credential_alg | COSE id: -7 ES256, -8 EdDSA, -257 RS256 |
role | device (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.
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:
| Field | Effect |
|---|---|
actor_assertion | WebAuthn assertion proving the caller holds a recovery-master authenticator |
rp_id | Relying party the actor assertion was scoped to |
allow_last | Permit removing the final enrollment (refused by default) |
revoke_sessions | Also 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.
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:
| Header | Row field | Notes |
|---|---|---|
X-Poweur-Device | id (hashed) | random per-install fingerprint; the relay keeps only dev_ + a hash |
X-Poweur-Device-Name | name | hostname, or "Safari on Mac", "iPhone" (≤ 64 bytes) |
X-Poweur-Device-Kind | kind | laptop, phone, browser, agent, … |
X-Poweur-Device-Client | client | app, web or cli; anything else is dropped |
X-Poweur-Device-Platform | platform | "macOS", "iOS", … (≤ 32 bytes) |
X-Poweur-Device-Browser | browser | web clients only (≤ 32 bytes) |
X-Poweur-Device-Enrollment | enrollment_id | the 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.
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.
| Endpoint | Purpose |
|---|---|
GET /drive/:identity | Root node, bytes used, quota |
POST /drive/:identity/chunks/missing | Which chunks to upload, and where (relay or presigned S3 URL) |
PUT /drive/:identity/chunks/:hash | Upload one encrypted chunk through the relay |
POST /drive/:identity/commit | Signed manifest, append records or a trim; 409 with head on a stale base |
GET /drive/:identity/changes?cursor=N | Changes 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/:hash | Chunk of an append record |
GET /drive/:identity/shares | Shares the caller may see (members: their own, with sealed keys) |
GET /drive/:identity/nodes/:node/shares | Shares on a node and its ancestors, for readers verifying version authors |
GET /drive/:identity/events | drive.changed SSE filtered to the caller; closes on revocation |
GET /drive/:identity/links/:link | Public: 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.