The device registry and public-key distribution: publishing the account's
cross-signing identity, registering a device with its signed key bundle, listing and
labelling devices, replenishing classical and ML-KEM one-time prekeys and MLS key
packages, maintaining the client-signed device-list log, reading peers' device lists,
and claiming key material to start sessions. All paths are under /api/v1; requests
and responses are JSON with base64-encoded binary values;
Authorization: Bearer <access token> is required everywhere (full scope except
where noted). Validation failures on this app return the DRF field-error object
directly, e.g. 400 {"ik_pub": ["invalid base64"]}, with no code key; the
project-wide 401 bodies listed in accounts/API.md apply here too.
Nothing in this app is verified server-side. Cross-signatures, prekey signatures,
identity self-signatures, and device-log hash chains are stored and relayed as opaque
bytes; every length and version check below is a malformed-input or anti-accident
guard, never a security control. The verifying party is always the peer client —
CLIENT_CONTRACT.md specifies that half.
Method: PUT
Path: /api/v1/me/identity
Uploads the account's cross-signing public keys: the master key, the self-signing key
(signs device bundles), the user-signing key (signs other users' master keys after
out-of-band verification), and master_sig, the master key's Ed25519 signature over
the canonical subkey encoding (chat:v1:cross-signing-keys — exact bytes and golden
vectors below). All are opaque to the server. The version
must be strictly greater than the stored one; the check exists so a stale client
cannot accidentally clobber a newer identity — a modified server would simply not
apply it, so clients must detect identity changes themselves (fetch + compare against
the out-of-band-verified master key).
A fresh account publishes its identity immediately after registering its first device (the first registration is the only one allowed without a published identity — see device registration below).
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Content-Type |
yes | application/json |
Request body
{
"master_pub": "bU1hc3RlcktleQ…",
"self_signing_pub": "c1NlbGZTaWdu…",
"user_signing_pub": "dVVzZXJTaWdu…",
"master_sig": "Z01hc3RlclNpZw…",
"version": 1
}Keys decode to 32–256 bytes (Ed25519 keys are 32); master_sig must decode to exactly
64 bytes. version fits a 32-bit column.
Responses
Empty body.
{ "master_sig": ["bad signature length"] }{ "code": "stale_version" }Sent when version ≤ the stored version; the stored identity is unchanged.
{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: GET
Path: /api/v1/users/{user_id}/identity
Returns the user's published identity verbatim — including after a master-key change: the server never smooths over, merges, or hides a substitution, because surfacing the new bytes is exactly what lets the client raise its safety-number-style alarm and block the conversation pending re-verification.
The client must verify signatures over the exact key bytes in this response, never look a key up by a server-supplied identifier and trust that instead. That mistake was CVE-2022-39250 in matrix-js-sdk (cross-signing identity injection, CVSS 8.6): checking and signing were two separate steps, so a malicious homeserver could substitute the key between them. The canonical encodings in this API are self-contained so no such indirection exists.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_id |
UUID | yes | Account whose identity is fetched |
Responses
{
"master_pub": "bU1hc3RlcktleQ…",
"self_signing_pub": "c1NlbGZTaWdu…",
"user_signing_pub": "dVVzZXJTaWdu…",
"master_sig": "Z01hc3RlclNpZw…",
"version": 3
}{ "code": "not_found" }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
The self-signing key signs every device's bundle. The signed payload is the ASCII
domain separator chat:v1:device-bundle followed by the length-prefixed concatenation
(4-byte big-endian length before each field) of, in this exact order:
| # | Field | Encoding |
|---|---|---|
| 1 | user_id |
16 bytes, UUID raw |
| 2 | device_id |
16 bytes, UUID raw |
| 3 | ik_pub |
raw bytes |
| 4 | spk_id |
4 bytes, big-endian |
| 5 | spk_pub |
raw bytes |
| 6 | pq_spk_id |
4 bytes, big-endian; zero-length field if absent |
| 7 | pq_spk_pub |
raw bytes; zero-length field if absent |
| 8 | registration_id |
4 bytes, big-endian |
| 9 | bundle_version |
4 bytes, big-endian |
The encoding embeds the key bytes, never key identifiers, so verifying it commits
the verifier to exactly the material the server served (the CVE-2022-39250 lesson
above). bundle_version is bumped by the client whenever any signed field changes, so
a stale signature is detectable client-side.
Load-bearing coupling: rotating the signed prekey (
spk) changes field 5, so the existingcross_siggoes stale. A client that rotatesspkwithout supplying a freshcross_sigand incrementedbundle_versionin the same request will be correctly rejected by peers. See "Replenish prekeys" below.
cross_sigcannot be computed at registration time. Field 2 is thedevice_idthatPOST /me/devicesassigns, so the first call goes out without a cross-signature and the client supplies it in the follow-upPUT /me/devices/{id}/prekeys. Full enrollment order:CLIENT_CONTRACT.md§M.
ik_pub is exactly 64 bytes: the device's Ed25519 signing public key (bytes 0–31)
followed by its X25519 identity public key (bytes 32–63). The Ed25519 half verifies
spk_sig and pq_spk_sig; the X25519 half is the identity key in X3DH/PQXDH. The
server's 32–256-byte range check deliberately does not enforce this — enforcing it
would mean the server parsing key material and committing to a curve. A peer whose
ik_pub is not 64 bytes is malformed to clients, not to the server.
Same rule as the bundle: ASCII domain separator, then a 4-byte big-endian length before each field. Distinct separators are what stop a signature from one context being replayed into another.
| Signature | Signed by | Domain separator | Fields, in order |
|---|---|---|---|
master_sig |
master key | chat:v1:cross-signing-keys |
user_id (16 B) · self_signing_pub (raw) · user_signing_pub (raw) |
spk_sig |
Ed25519 half of ik_pub |
chat:v1:signed-prekey |
user_id (16 B) · spk_id (4 B BE) · spk_pub (raw) |
pq_spk_sig |
Ed25519 half of ik_pub |
chat:v1:pq-signed-prekey |
user_id (16 B) · pq_spk_id (4 B BE) · pq_spk_pub (raw) |
master_sig does not cover version: the version is this server's anti-accident
monotonic check, and signing it would imply the served number carries a guarantee it
does not. The two prekey signatures do not cover device_id, which would otherwise be
unknowable at registration — the bundle above is what binds a prekey to a device.
Golden vectors for all four encodings: devices/vectors/. Reproducing them byte for
byte is the only way two client platforms can be sure they agree; the server never
computes or checks any of it.
Method: POST, GET
Path: /api/v1/me/devices
POST registers a cryptographic device: identity key, signed prekey with signature,
registration id, optional ML-KEM-768 signed prekey and one-time prekeys, optional
encrypted label, and optional initial classical one-time prekeys and key packages, all
in one transaction. It is the only endpoint that accepts a register-scope token, and it
responds with a full-scope token pair for the new device. Live devices are capped per
account (default 10); revoked devices do not count.
cross_sig and bundle_version are not accepted here. Sending either is a 400
whose message names the endpoint that does accept them. No valid value exists: the
canonical bundle above covers device_id (field 2), and this request is what mints it,
so a cross-signature computed before the 201 cannot cover the device it describes. A
later device has a second reason: cross-signing needs the account's self-signing private
key, which lives in the key backup, and reading that needs the full-scope token this
endpoint returns. The cross-signature therefore belongs in the client's next call,
PUT /me/devices/{id}/prekeys.
Every device is consequently born with cross_sig null — the "never cross-signed" state
peers already refuse — and null here does not mean a pre-cross-signing device. The
field is refused rather than quietly ignored because storing a signature over the wrong
device_id is worse than storing nothing: peers read null as "unverified, withhold", but
read the later correction as a cross-signature change, which CLIENT_CONTRACT.md
§D/§E requires them to treat as a safety-number event and block the conversation over.
Any registration past the account's first live device additionally requires a published
identity (400 {"code":"identity_required"} — the first device is exempt because a
register-scope token cannot have published one yet). None of these are security
controls: a modified server would skip them, and peers must reject unverifiable devices
regardless. What they buy is that a client cannot believe it cross-signed a device it
did not. The full enrollment order is CLIENT_CONTRACT.md §M.
GET (full scope only) lists the caller's live devices with their encrypted labels
and coarse dates, marking which entry is the calling device, plus log_head_seq, the
head of the account's device-list log (null when empty). The response carries an
ETag; poll with If-None-Match and treat 304 as "nothing changed". The tag covers
the live device set and the device-log head, so a log append also invalidates it —
expect more frequent invalidation than under the device-set-only tag.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>; POST accepts register or full scope, GET requires full |
Content-Type |
POST only | application/json |
If-None-Match |
no (GET) | Previous ETag value |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| — | none |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body (POST)
{
"ik_pub": "mNfJ2kLxWQ…",
"spk_id": 1,
"spk_pub": "aX9cR3tYvB…",
"spk_sig": "Zk4wPq8sLm…",
"registration_id": 4242,
"pq_spk": { "spk_id": 1, "pub": "cVBxU3BrS2V5…", "sig": "c1BxU2ln…" },
"pq_otpks": [ { "key_id": 1, "pub": "cVBxT3Rwaw…" } ],
"label_blob": "cGFkZGVkLWxhYmVs…",
"otpks": [ { "key_id": 1, "pub": "b2t0cGtl…" } ],
"keypackages": [ "a2V5cGFja2FnZQ…" ]
}Classical key fields decode to 32–256 bytes; pq_spk.sig to exactly 64 bytes (Ed25519);
PQ public keys to exactly 1184 bytes (an ML-KEM-768 encapsulation key is fixed-size, so
the exact check rejects nothing a real client would send). ik_pub is the 64-byte
Ed25519-then-X25519 pair the client contract defines; the server's range check is
deliberately looser than that and commits to nothing about the bytes, so a wrong-sized
ik_pub is caught only by peers.
otpks ≤ 200 and pq_otpks ≤ 100 items with unique key_ids per list;
keypackages ≤ 100, each exactly one key-package bucket (4096 or 16384 bytes);
label_blob optional, one label bucket (256 or 1024 bytes). Integer ids must fit a
32-bit column. Every signature is stored and relayed, never verified server-side;
verification is the peer client's job.
Responses
{
"device_id": "9f1c6a2e-3b7d-4e0f-8c15-2a77d4b9e611",
"access": "eyJhbGciOiJIUzI1NiIs…",
"refresh": "eyJhbGciOiJIUzI1NiIs…",
"scope": "full"
}{
"devices": [
{
"device_id": "9f1c6a2e-3b7d-4e0f-8c15-2a77d4b9e611",
"label_blob": "cGFkZGVkLWxhYmVs…",
"created_date": "2026-07-20",
"last_active_date": "2026-07-23",
"this_device": true
}
],
"log_head_seq": 4
}label_blob, last_active_date, and log_head_seq may be null. Header:
ETag: "5b3a9c…".
Empty body, when If-None-Match matches the current ETag.
{ "otpks": { "0": { "pub": ["invalid base64"] } } }{ "code": "bad_bucket", "detail": "Invalid payload." }{ "cross_sig": "Not accepted at registration: the canonical device bundle covers device_id, which this request assigns, so no signature computed before the response can be valid. Send cross_sig and bundle_version to PUT /me/devices/{device_id}/prekeys once you have the device_id." }{ "code": "identity_required", "detail": "Publish a cross-signing identity before adding another device." }{ "code": "device_limit", "detail": "This account has too many devices." }{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: PUT, DELETE
Path: /api/v1/me/devices/{device_id}
PUT replaces the device's encrypted label. DELETE revokes the device: its token
generation is bumped (killing every outstanding access and refresh token), its
one-time prekeys (classical and ML-KEM), key packages (last-resort included), and
queued envelopes are deleted in one transaction, any live WebSocket is closed with
code 4003, and it disappears from device lists. Revocation is permanent; a "re-added"
device is a new registration. Any device of the account may revoke any other (or
itself). The client appends a device-list log record for the removal
(CLIENT_CONTRACT.md §J).
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Content-Type |
PUT only | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
device_id |
UUID | yes | A live device belonging to the caller |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body (PUT)
{ "label_blob": "cGFkZGVkLWxhYmVs…" }Responses
Empty body.
Empty body.
{ "label_blob": ["This field is required."] }An off-bucket label is 400 {"code": "bad_bucket", "detail": "Invalid payload."}.
{ "code": "not_found", "detail": "No such device." }Another account's device id is a 404, not a 403, so existence is not confirmed.
{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: PUT
Path: /api/v1/me/devices/{device_id}/prekeys
Uploads new one-time prekeys (classical and/or ML-KEM) and/or rotates the signed
prekeys for the calling device. Only the device itself may call this: the token's
device_id must equal the path. Re-uploading a key_id the device already stored is
an idempotent retry, not an error; a duplicated key_id inside one payload is a
400. The classical pool is capped at 200 and the PQ pool at 100; a batch that would
cross either cap is refused whole and, in that case, nothing else in the request is
applied either — no prekey rotation, no signature update.
This is also where a device's first
cross_sigarrives. Registration cannot carry one — the canonical bundle covers thedevice_idthat registration assigns — so every newly registered device cross-signs itself here, with thedevice_idfrom its201. SeeCLIENT_CONTRACT.md§M for the full order.
Rotating
spkstales yourcross_sig. The signed prekey is a field of the canonical device bundle, so replacing it invalidates the stored cross-signature. Send a freshcross_sigand an incrementedbundle_versionin the same call, or peers fetching your bundle will correctly reject this device. Sending one without the other is a400; beyond that the server stores whatever it is given and never checks that the signature matches the bundle — only peers can, so there is no server error to save a client that signs the wrong bytes.
Clients watch the count endpoint (or the otpk_count in this response) and replenish
when the pools run low, since every peer claim consumes one of each.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope, bound to {device_id} |
Content-Type |
yes | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
device_id |
UUID | yes | Must equal the token's own device id |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
{
"spk": { "spk_id": 7, "pub": "aX9cR3tYvB…", "sig": "Zk4wPq8sLm…" },
"cross_sig": "eENyb3NzU2ln…",
"bundle_version": 2,
"pq_spk": { "spk_id": 3, "pub": "cVBxU3BrS2V5…", "sig": "c1BxU2ln…" },
"pq_otpks": [ { "key_id": 12, "pub": "cVBxT3Rwaw…" } ],
"otpks": [ { "key_id": 41, "pub": "b2t0cGtl…" } ]
}All fields optional; an empty body is a no-op that still returns the count. PQ public
keys must decode to exactly 1184 bytes; cross_sig and pq_spk.sig to exactly 64.
Responses
{ "otpk_count": 173 }{ "otpks": "duplicate key_id" }{ "code": "forbidden", "detail": "This token does not belong to that device." }{ "code": "prekey_limit", "detail": "Too many stored one-time prekeys for this device." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: GET
Path: /api/v1/me/devices/{device_id}/prekeys/count
Reports how many one-time prekeys — classical and ML-KEM — the calling device still has stored. Same self-only gate as replenishment.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope, bound to {device_id} |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
device_id |
UUID | yes | Must equal the token's own device id |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
None.
Responses
{ "otpk_count": 173, "pq_otpk_count": 88 }{ "code": "forbidden", "detail": "This token does not belong to that device." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: PUT
Path: /api/v1/me/devices/{device_id}/keypackages
Uploads MLS key packages for the calling device (self-only, like prekeys). Each blob must be exactly one key-package bucket (4096 or 16384 bytes — sized for PQ ciphersuites; pad accordingly). The consumable store is capped at 100 per device; a batch that would cross the cap is refused whole, so the client is told rather than silently truncated.
With "is_last_resort": true the request must carry exactly one blob, which
replaces the device's single last-resort package (at most one exists; re-uploading
after a reinstall is idempotent). The last-resort package is what claims fall back to
when the consumable pool is exhausted — it is served without being deleted, at a
forward-secrecy cost on the initial group join (see the claim endpoint), which is why
a healthy client keeps the consumable pool stocked and treats the last-resort package
as insurance, not inventory. It lives outside the 100-cap and outside
keypackage_count.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope, bound to {device_id} |
Content-Type |
yes | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
device_id |
UUID | yes | Must equal the token's own device id |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
{ "keypackages": [ "a2V5cGFja2FnZQ…", "c2Vjb25kLXBhY2s…" ], "is_last_resort": false }≤ 100 per request; both fields optional (an empty upload returns the count);
is_last_resort: true requires exactly one blob.
Responses
{ "keypackage_count": 42 }The count covers the consumable pool only — the last-resort package is never counted, because it never leaves and would otherwise mask exhaustion.
{ "keypackages": ["Ensure this field has no more than 100 elements."] }An off-bucket blob is 400 {"code": "bad_bucket", "detail": "Invalid payload."}.
{ "code": "forbidden", "detail": "This token does not belong to that device." }{ "code": "keypackage_limit", "detail": "Too many stored key packages for this device." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: GET
Path: /api/v1/me/devices/{device_id}/keypackages/count
Reports how many consumable key packages the calling device still has stored (the last-resort package is excluded). Self-only.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope, bound to {device_id} |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
device_id |
UUID | yes | Must equal the token's own device id |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
None.
Responses
{ "keypackage_count": 42 }{ "code": "forbidden", "detail": "This token does not belong to that device." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: GET
Path: /api/v1/users/{user_id}/devices
The public identity of a peer's live devices: device id, identity key, registration
id, cross-signature, and bundle version, ordered by id — what a sender needs to fan a
message out to every device and to verify each device against the owner's published
identity. cross_sig is served verbatim, null included: a device that was never
cross-signed must be visible as unsigned so peers can refuse it (the server never
substitutes or synthesizes a signature). The response also carries log_head_seq, the
head of the user's device-list log. Labels, dates, and activity are omitted; those
belong to the owner. The response carries an ETag (also mirrored in the body), and
an unknown user is indistinguishable from a user with no live devices.
Poll with If-None-Match before encrypting to a peer; a changed tag means a device
was added or removed or the device log was appended to (the tag covers both), and
session state / log-head comparison should be refreshed.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
If-None-Match |
no | Previous ETag value |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_id |
UUID | yes | Account whose devices are listed |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
None.
Responses
{
"devices": [
{
"device_id": "2a77d4b9-e611-4c0f-9f1c-6a2e3b7d4e0f",
"ik_pub": "mNfJ2kLxWQ…",
"registration_id": 4242,
"cross_sig": "eENyb3NzU2ln…",
"bundle_version": 2
}
],
"etag": "\"5b3a9c1d7e2f4a6b8c0d1e2f3a4b5c6d\"",
"log_head_seq": 4
}Header: ETag: "5b3a9c1d7e2f4a6b8c0d1e2f3a4b5c6d". cross_sig and log_head_seq
may be null. An unknown or deactivated user yields {"devices": [], "etag": …, "log_head_seq": null} with 200.
Empty body.
{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: POST
Path: /api/v1/users/{user_id}/keys/claim
Returns a hybrid PQXDH bundle for each of the target user's live devices, atomically
consuming one classical and (when the device has PQ material) one ML-KEM one-time
prekey per device (each key is handed out at most once, even under concurrent
claims). When a device's classical pool is empty the bundle is served without the
otpk field; the client falls back per protocol. Claiming your own user_id is the
normal self-sync path.
When a device holds no PQ signed prekey, every PQ field is omitted from its bundle — never null-filled, zeroed, or substituted with classical material — so a classical-only bundle is visibly classical-only. Deciding whether to refuse it or proceed with a clearly-flagged classical-only session is the client's job (CLIENT_CONTRACT.md §F: never silently downgrade).
Call it when starting a session with a peer. An explicit "device_ids": [] claims
nothing — the empty list is honoured, not treated as "all" — so batch carefully.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Content-Type |
yes | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_id |
UUID | yes | Owner of the devices whose prekeys are claimed |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
{ "device_ids": ["2a77d4b9-e611-4c0f-9f1c-6a2e3b7d4e0f"] }Optional (≤ 100 ids). Omitted entirely means "all live devices of that user".
Responses
{
"bundles": [
{
"device_id": "2a77d4b9-e611-4c0f-9f1c-6a2e3b7d4e0f",
"registration_id": 4242,
"ik_pub": "mNfJ2kLxWQ…",
"spk_id": 7,
"spk_pub": "aX9cR3tYvB…",
"spk_sig": "Zk4wPq8sLm…",
"cross_sig": "eENyb3NzU2ln…",
"bundle_version": 2,
"pq_spk_id": 3,
"pq_spk_pub": "cVBxU3BrS2V5…",
"pq_spk_sig": "c1BxU2ln…",
"pq_otpk": { "key_id": 12, "pub": "cVBxT3Rwaw…" },
"otpk": { "key_id": 41, "pub": "b2t0cGtl…" }
}
]
}otpk is absent when that device's classical pool is empty; pq_otpk when its PQ
pool is empty; all four pq_* fields when the device has no PQ signed prekey.
cross_sig is null for a device that was never cross-signed (verify before
encrypting — CLIENT_CONTRACT.md §C/§E). An unknown user or one with no live devices
yields {"bundles": []} with 200.
{ "device_ids": { "0": ["Must be a valid UUID."] } }{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope claim, default 120/min.
Method: POST
Path: /api/v1/users/{user_id}/keypackages/claim
Returns one MLS key package per targeted live device. A consumable (non-last-resort) package is preferred: oldest first, deleted on claim, each handed out exactly once. When a device's consumable pool is exhausted, its last-resort package is served instead — without being deleted, so the device stays addable to groups. A reused last-resort package weakens forward secrecy for the initial group join (every join that used it shares one KEM secret, so a later compromise of that key exposes each such join's Welcome); it is a fallback, not an equivalent. Devices with neither are simply absent from the response; an exhausted store is not an error. Used when adding a user's devices to a group.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Content-Type |
yes | application/json |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_id |
UUID | yes | Owner of the devices whose key packages are claimed |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| — | none |
Request body
{ "device_ids": ["2a77d4b9-e611-4c0f-9f1c-6a2e3b7d4e0f"] }Optional, same semantics as the prekey claim.
Responses
{
"keypackages": [
{ "device_id": "2a77d4b9-e611-4c0f-9f1c-6a2e3b7d4e0f", "blob": "a2V5cGFja2FnZQ…" }
]
}{ "device_ids": ["Ensure this field has no more than 100 elements."] }{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope claim, default 120/min.
Method: POST
Path: /api/v1/me/devicelog
Appends client-signed, hash-chained records to the account's device-list log. The
client appends one on every device-set change (add, remove, revoke) and on identity
rotation. Record contents are client-defined and opaque — the expected structure is
prev_hash, a hash of the canonical device-set encoding, the current identity
version, the record seq, a coarse timestamp, and an Ed25519 signature by the
self-signing key over all of it — but the server never parses or validates any of
that, including the hash chain. A record whose prev_hash links to nothing is
accepted and served verbatim; server-side chain validation would be fake enforcement
that trains clients to skip the head comparison which actually detects equivocation.
Two honest clients holding different heads for the same user is cryptographic proof
the server equivocated — detection, not prevention, and only once two clients
actually compare (clients gossip heads inside ordinary E2EE messages).
Appending changes the account's device-list ETag, so polling peers re-fetch and see
the new head.
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Content-Type |
yes | application/json |
Request body
{ "records": [ { "blob": "ZGV2aWNlbG9nLXJlY29yZA…" } ] }1–50 records, each exactly one device-log bucket (256 or 1024 bytes).
Responses
{ "first_seq": 5, "last_seq": 5 }Sequence numbers are server-assigned, per-user, contiguous within a batch, starting at 0.
{ "code": "bad_bucket", "detail": "Invalid payload." }{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.
Method: GET
Path: /api/v1/users/{user_id}/devicelog
Keyset-paged read of a user's device-list log, records served byte-identically in seq order — no repair, no reordering, no dropping of non-linking records (see the append endpoint for why). On fetch, the client verifies the head signature and that the head extends the last head it saw for that user; a fork is proof of server equivocation (CLIENT_CONTRACT.md §C/§J).
Headers
| Header | Required | Value |
|---|---|---|
Authorization |
yes | Bearer <access token>, full scope |
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
user_id |
UUID | yes | Account whose log is read |
Query parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
after |
int | no | start of log | Return records with seq greater than this |
limit |
int | no | 200 | Page size, clamped into [1, 200] |
A non-numeric after falls back to the start; a bad limit falls back to the cap —
never a 500.
Request body
None.
Responses
{
"records": [ { "seq": 4, "blob": "ZGV2aWNlbG9nLXJlY29yZA…" } ],
"has_more": false,
"head_seq": 4
}head_seq is null and records empty for an empty log, an unknown user, or a
deactivated user.
{ "code": "scope_forbidden", "detail": "This token cannot access this endpoint." }{ "detail": "Request was throttled." }Scope accounts, default 120/min.