An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.
1**Provisioning API**
2
3Design Specification
4
5Desktop PDS Relay Layer
6
7Version 0.3 --- Mobile-First Reconciliation
8
9March 2026
10
11*v0.3 Changes — Mobile-First Reconciliation + Endpoint Consolidation*
12
13*Reconciled with mobile architecture spec v1.2 (canonical) and migration spec v0.1.*
14*All endpoints tagged with milestone phase.*
15
16*FIX Ed25519 → P-256/secp256k1 throughout (ATProto requirement)*
17*FIX DID ceremony: client sends key material, relay constructs DID doc*
18*FIX Tier model: Free/Pro/Business + BYO as deployment model*
19*NEW POST /v1/accounts/mobile — combined mobile account creation*
20*NEW 9 endpoints from mobile spec (relay keys, device mgmt, signing)*
21*NEW 8 endpoints from migration spec (transfer, recovery, Shamir)*
22*NEW Blob endpoints (uploadBlob, getBlob, listBlobs)*
23*NEW Milestone tags on all endpoint groups*
24
25**CONFIDENTIAL**
26
271\. Overview
28
29This document specifies the provisioning API for the Desktop PDS relay layer. The API orchestrates five flows: account creation, device binding, DID ceremony, handle/domain setup, and account exit. Two client types consume the API: the web dashboard (account lifecycle, billing) and the Tauri desktop app (device binding, runtime operations).
30
31All endpoints are served over HTTPS at the relay's base URL. The API follows REST conventions with JSON request/response bodies. Authentication uses Bearer tokens with three scopes: account, session, and device.
32
331.1 Base URL
34
35> https://relay.{service-domain}/v1
36
371.2 Identity Model
38
39The relay supports two DID methods. The choice is made during the setup wizard and cannot be changed after the DID ceremony completes.
40
41 ------------ ------------- ----------------------------------- ---------------------------------------------------------------------------------------------
42 **Method** **Default** **Requirement** **Exit Story**
43 did:plc Yes Available to all tiers User signs a PLC operation to repoint service endpoint. Clean exit, zero ongoing liability.
44 did:web No Custom domain required (Pro tier) User controls the domain, repoints DNS at new PDS. Zero ongoing liability.
45 ------------ ------------- ----------------------------------- ---------------------------------------------------------------------------------------------
46
47> ***Design Decision:** did:web is only available to users who bring their own domain. Subdomain-based did:web is not offered because it creates exit liability --- the relay would be obligated to host the DID document indefinitely after the user leaves.*
48
491.3 Authentication Model
50
51The API uses three token types, each scoped to a specific client and lifetime:
52
53 ---------------- --------------------- ---------------------------- --------------------------------------------
54 **Token** **Issued To** **Lifetime** **Scope**
55 session\_token Web dashboard 24 hours (renewable) Account management, billing, handle config
56 device\_token Tauri app Long-lived (until revoked) Relay connection, DID ops, sync
57 claim\_code Web → Tauri handoff 15 minutes (single-use) Device registration only
58 ---------------- --------------------- ---------------------------- --------------------------------------------
59
60All authenticated requests must include the token in the Authorization header:
61
62> Authorization: Bearer {token}
63>
64> ***Design Decision:** All users authenticate through the web dashboard, including self-hosted relay operators. There is no static\_token bypass. One auth path means one security model to audit.*
65
661.4 Rate Limiting
67
68All endpoints are rate-limited per token. Free-tier accounts have stricter limits. When a limit is hit, the API returns 429 Too Many Requests with a Retry-After header.
69
70 ------------- ------------ ----------- ---------------
71 **Tier** **Writes** **Reads** **Burst**
72 Free 30/min 120/min 5 concurrent
73 Pro 120/min 600/min 20 concurrent
74 Business 300/min 1200/min 50 concurrent
75 ------------- ------------ ----------- ---------------
76
77Note: BYO relay operators configure their own rate limits. BYO is a deployment model, not a subscription tier. See §6.3 for BYO relay configuration.
78
791.5 Error Envelope
80
81All error responses use a consistent JSON envelope:
82
83> {
84>
85> \"error\": {
86>
87> \"code\": \"ACCOUNT\_EXISTS\",
88>
89> \"message\": \"An account with this email already exists.\",
90>
91> \"details\": { \... } // optional, endpoint-specific
92>
93> }
94>
95> }
96
972\. Account Lifecycle
98
99Account endpoints are consumed by the web dashboard. They handle signup, authentication, and account management. Accounts start on the free tier with usage caps enforced at the relay level.
100
101**POST /v1/accounts** [v0.1]
102
103Create a new account. Returns session credentials and a one-time claim code for device binding.
104
105**Request Body**
106
107 --------------- ---------- -------------- -----------------------
108 **Field** **Type** **Required** **Description**
109 email string yes User's email address
110 password string yes Minimum 12 characters
111 display\_name string no Optional display name
112 --------------- ---------- -------------- -----------------------
113
114**Response (200 OK)**
115
116 ---------------- ---------- --------------------------------------------
117 **Field** **Type** **Description**
118 account\_id string UUID v7 account identifier
119 session\_token string JWT, 24-hour expiry
120 claim\_code string 6-character alphanumeric, 15-minute expiry
121 tier string Always \"free\" on creation
122 ---------------- ---------- --------------------------------------------
123
124**Error Responses**
125
126 ------------ ----------------- ---------------------------------------
127 **Status** **Code** **Description**
128 409 ACCOUNT\_EXISTS Email already registered
129 422 WEAK\_PASSWORD Password doesn't meet requirements
130 429 RATE\_LIMITED Too many signup attempts from this IP
131 ------------ ----------------- ---------------------------------------
132
133> ***Note:** The claim\_code is displayed in the web dashboard for the user to paste into their Tauri app. It is single-use and expires after 15 minutes. A new one can be generated via POST /v1/accounts/claim-codes.*
134
135**POST /v1/accounts/mobile** [v0.1]
136
137Combined account creation for mobile clients. Creates the account, binds the device, generates the relay signing key, and initiates the DID ceremony in one request. Replaces the multi-step web dashboard flow for iOS users.
138
139**Request Body**
140
141 ----------------------- ---------- -------------- -------------------------------------------------------
142 **Field** **Type** **Required** **Description**
143 email string yes User's email address
144 password string yes Minimum 12 characters
145 display_name string no Optional display name
146 device_public_key string yes P-256 public key from Secure Enclave
147 device_name string no e.g. "iPhone 15 Pro"
148 rotation_pub_key string yes P-256 rotation key (stays on device)
149 handle string no Desired handle (subdomain assigned if omitted)
150 did_method string no "did:plc" (default) or "did:web"
151 ----------------------- ---------- -------------- -------------------------------------------------------
152
153**Response (200 OK)**
154
155 ----------------------- ---------- -------------------------------------------------------
156 **Field** **Type** **Description**
157 account_id string UUID v7 account identifier
158 device_id string UUID v7 device identifier
159 device_token string Long-lived opaque token
160 session_token string JWT, 24-hour expiry
161 did string Fully qualified DID string
162 did_document object The constructed DID document
163 handle string Assigned handle
164 relay_endpoint object Relay Endpoint Object (see §3.2)
165 relay_signing_key string Key ID of the relay's signing key
166 tier string Always "free" on creation
167 shamir_share_1 string Encrypted share for iCloud Keychain storage
168 shamir_share_3_options array Available storage methods for Share 3
169 ----------------------- ---------- -------------------------------------------------------
170
171**Error Responses**
172
173 ------------ ----------------------- -------------------------------------------------------
174 **Status** **Code** **Description**
175 409 ACCOUNT_EXISTS Email already registered
176 422 WEAK_PASSWORD Password doesn't meet requirements
177 422 INVALID_KEY Public key is malformed or unsupported curve
178 409 HANDLE_TAKEN Requested handle is already in use
179 429 RATE_LIMITED Too many signup attempts
180 ------------ ----------------------- -------------------------------------------------------
181
182Note: This endpoint performs Shamir share generation as part of account creation. Share 1 is returned in the response for the client to store in iCloud Keychain. Share 2 is escrowed at the relay. Share 3 handling depends on user choice (communicated in a follow-up call or during onboarding flow).
183
184**POST /v1/accounts/sessions** [v0.1]
185
186Authenticate and obtain a session token. Supports email/password and refresh token flows.
187
188**Request Body**
189
190 ---------------- ---------- -------------- ----------------------------------------
191 **Field** **Type** **Required** **Description**
192 email string yes\* Required for password auth
193 password string yes\* Required for password auth
194 refresh\_token string yes\* Alternative: renew an existing session
195 ---------------- ---------- -------------- ----------------------------------------
196
197**Response (200 OK)**
198
199 ---------------- ---------- -----------------------------
200 **Field** **Type** **Description**
201 session\_token string JWT, 24-hour expiry
202 refresh\_token string Opaque token, 30-day expiry
203 account\_id string UUID v7
204 ---------------- ---------- -----------------------------
205
206**Error Responses**
207
208 ------------ ---------------------- ---------------------------
209 **Status** **Code** **Description**
210 401 INVALID\_CREDENTIALS Email/password mismatch
211 401 TOKEN\_EXPIRED Refresh token has expired
212 423 ACCOUNT\_LOCKED Too many failed attempts
213 ------------ ---------------------- ---------------------------
214
215**POST /v1/accounts/claim-codes** [v0.1]
216
217Generate a new device claim code. Invalidates any previously active claim code for this account.
218
219**Response (200 OK)**
220
221 ------------- ---------- --------------------------------------------
222 **Field** **Type** **Description**
223 claim\_code string 6-character alphanumeric, 15-minute expiry
224 expires\_at string ISO 8601 timestamp
225 ------------- ---------- --------------------------------------------
226
227**Error Responses**
228
229 ------------ --------------- ----------------------------------
230 **Status** **Code** **Description**
231 401 UNAUTHORIZED Invalid or missing session token
232 429 RATE\_LIMITED Max 5 claim codes per hour
233 ------------ --------------- ----------------------------------
234
235> ***Note:** Requires session\_token authentication.*
236
237**GET /v1/accounts/:id/usage** [v0.1]
238
239Returns current usage metrics for the account. Consumed by both the web dashboard (billing page) and the Tauri app (status bar indicator).
240
241**Response (200 OK)**
242
243 ------------------ ---------- -------------------------------------------------------
244 **Field** **Type** **Description**
245 tier string Current tier: \"free\" \| \"pro\" \| \"self\_hosted\"
246 period\_start string ISO 8601, start of current billing period
247 storage\_bytes integer Repo storage consumed
248 storage\_limit integer Tier limit in bytes
249 bandwidth\_bytes integer Relay bandwidth this period
250 bandwidth\_limit integer Tier limit in bytes
251 requests\_count integer XRPC requests proxied this period
252 requests\_limit integer Tier limit
253 ------------------ ---------- -------------------------------------------------------
254
255**Error Responses**
256
257 ------------ -------------- --------------------------------
258 **Status** **Code** **Description**
259 401 UNAUTHORIZED Invalid token
260 403 FORBIDDEN Token doesn't own this account
261 ------------ -------------- --------------------------------
262
263> ***Note:** Both session\_token and device\_token can access this endpoint, but only for their own account.*
264
2653\. Device Registration
266
267Device endpoints are consumed by the Tauri app. The claim code handoff binds a specific device to an account, and the device\_token becomes the app's long-lived credential for all relay interactions.
268
2693.1 Multi-Device Model
270
271Pro accounts support up to 5 devices. To maintain a linear commit chain on the ATProto repo (required for federation), the relay enforces a primary-device model with lease-based write ownership.
272
273- **Primary device:** Holds the write lease. Can commit to the repo, push to the relay, and trigger federation events.
274
275- **Secondary devices:** Read-only replicas that sync from the relay. They see the full repo but cannot commit. A secondary can request promotion to primary.
276
277- **Lease transfer:** Explicit via the web dashboard or Tauri app. The current primary relinquishes the lease, the requesting device acquires it. If the primary is offline for longer than the lease TTL (configurable, default 24 hours), the lease expires and any device can claim it.
278
279> ***Design Decision:** Primary-device with lease was chosen over last-write-wins or conflict queues. LWW risks lost writes on rebase; conflict queues require users to resolve Merkle tree forks. Primary-device sidesteps the problem entirely and matches how most people use desktop apps.*
280
281**POST /v1/devices** [v0.1]
282
283Register a device by redeeming a claim code. The Tauri app generates a keypair locally and sends the public key. The relay binds the device and returns a device token. The first device registered to an account automatically receives the primary write lease.
284
285**Request Body**
286
287 --------------------- ---------- -------------- --------------------------------------------
288 **Field** **Type** **Required** **Description**
289 claim\_code string yes 6-character code from web dashboard
290 device\_public\_key string yes P-256 (secp256r1) public key, base64url-encoded
291 device\_name string no Human-readable label, e.g. \"MacBook Pro\"
292 os string no Operating system identifier
293 app\_version string no Tauri app version string
294 --------------------- ---------- -------------- --------------------------------------------
295
296**Response (200 OK)**
297
298 ----------------- ---------- -------------------------------------------
299 **Field** **Type** **Description**
300 device\_id string UUID v7 device identifier
301 device\_token string Long-lived opaque token
302 account\_id string Bound account UUID
303 is\_primary boolean Whether this device holds the write lease
304 relay\_endpoint object See Relay Endpoint Object below
305 ----------------- ---------- -------------------------------------------
306
307**Error Responses**
308
309 ------------ ---------------- ---------------------------------------------------
310 **Status** **Code** **Description**
311 400 INVALID\_CLAIM Claim code is invalid, expired, or already used
312 409 DEVICE\_LIMIT Account has reached maximum device count for tier
313 422 INVALID\_KEY Public key is malformed or unsupported curve
314 ------------ ---------------- ---------------------------------------------------
315
316> ***Note:** The device private key never leaves the Tauri app. The relay stores only the public key for challenge-response verification during reconnection.*
317
3183.2 Relay Endpoint Object
319
320Returned by device registration and the relay info endpoint:
321
322 ---------------- ---------- --------------------------------------------
323 **Field** **Type** **Description**
324 host string Relay hostname
325 port integer Relay port (typically 443)
326 iroh\_node\_id string Iroh node identifier for direct connection
327 region string Relay region code, e.g. \"us-east-1\"
328 protocol string Connection protocol: \"iroh\" \| \"wss\"
329 ---------------- ---------- --------------------------------------------
330
331**GET /v1/devices/:id/relay** [v0.1]
332
333Retrieve the assigned relay endpoint for a device. Used by the Tauri app on startup to discover where to connect.
334
335**Response (200 OK)**
336
337 ----------------- ---------- ---------------------------------------------------------
338 **Field** **Type** **Description**
339 relay\_endpoint object Relay Endpoint Object (see 3.2)
340 status string Relay status: \"online\" \| \"degraded\" \| \"offline\"
341 buffer\_depth integer Messages buffered while device was offline
342 ----------------- ---------- ---------------------------------------------------------
343
344**Error Responses**
345
346 ------------ -------------------- ----------------------------------------
347 **Status** **Code** **Description**
348 401 UNAUTHORIZED Invalid device token
349 404 DEVICE\_NOT\_FOUND Device ID doesn't exist or was revoked
350 ------------ -------------------- ----------------------------------------
351
352**POST /v1/devices/:id/lease** [v1.0]
353
354Request or release the primary write lease for a device.
355
356**Request Body**
357
358 ----------- ---------- -------------- ----------------------------
359 **Field** **Type** **Required** **Description**
360 action string yes \"acquire\" or \"release\"
361 ----------- ---------- -------------- ----------------------------
362
363**Response (200 OK)**
364
365 -------------------- ---------- ---------------------------------------------------------------
366 **Field** **Type** **Description**
367 is\_primary boolean Whether this device now holds the lease
368 lease\_expires\_at string ISO 8601, when the lease auto-expires if not renewed
369 previous\_primary string Device ID of the previous primary (null if lease was expired)
370 -------------------- ---------- ---------------------------------------------------------------
371
372**Error Responses**
373
374 ------------ ---------------------- ---------------------------------------------------------------------------------
375 **Status** **Code** **Description**
376 409 LEASE\_HELD Another device holds an active lease. Must wait for expiry or explicit release.
377 403 SINGLE\_DEVICE\_TIER Free tier accounts have only one device; lease management is not applicable.
378 ------------ ---------------------- ---------------------------------------------------------------------------------
379
380> ***Note:** The Tauri app should silently renew the lease by calling this endpoint periodically (recommended: every 6 hours). If the primary device is offline beyond the lease TTL (default 24h), any other device can acquire the lease.*
381
382**DELETE /v1/devices/:id** [v1.0]
383
384Revoke a device. Invalidates its device\_token and disconnects it from the relay. If the revoked device was primary, the lease is released. Buffered messages are held for 72 hours before purging.
385
386**Response (200 OK)**
387
388 ------------------- ---------- -----------------------------------------------------------
389 **Field** **Type** **Description**
390 revoked\_at string ISO 8601 timestamp
391 buffer\_purge\_at string ISO 8601, when buffered messages will be deleted
392 lease\_released boolean Whether the primary lease was released by this revocation
393 ------------------- ---------- -----------------------------------------------------------
394
395**Error Responses**
396
397 ------------ -------------- -------------------------------
398 **Status** **Code** **Description**
399 401 UNAUTHORIZED Invalid token
400 403 FORBIDDEN Token doesn't own this device
401 ------------ -------------- -------------------------------
402
403> ***Note:** Requires session\_token (web dashboard). Device tokens cannot self-revoke.*
404
4054\. DID Ceremony
406
407The DID ceremony binds a decentralized identifier to the user's device and relay. The Tauri app generates the DID document locally (the private key never leaves the device) and submits it to the relay for registration. The DID is the user's identity --- the follow graph, repo, and all social connections reference it. There is no such thing as migrating from one DID to another; that would be creating a new account.
408
4094.1 PLC Mirror
410
411The relay operates a PLC directory mirror to improve resolution speed and provide resilience against upstream outages. The mirror starts as read-only (caching and serving existing PLC documents) and may graduate to a read-write authority in a future version.
412
413 ----------- ---------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
414 **Phase** **Mode** **Behavior**
415 v1.0 Read-only cache Mirrors the PLC directory. Serves cached DID documents for faster resolution. Falls back to upstream plc.directory if cache misses. Provides resilience if upstream is temporarily unavailable.
416 Future Read-write authority Can accept and validate new PLC operations independently. Participates in the PLC directory network as a peer.
417 ----------- ---------------------- -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
418
419> ***Note:** New DID operations (creation, rotation) are submitted to plc.directory via the relay as a proxy in v1.0. The mirror is purely for read-side performance and resilience.*
420
421**POST /v1/dids** [v0.1]
422
423Initiate a DID ceremony. The client submits key material (public keys only — private keys never leave the device). The relay constructs the DID document, including verification methods from submitted keys, service endpoint pointing to the relay, and handle (atproto_handle alias).
424
425For did:plc, the relay constructs and signs the genesis operation, then submits it to plc.directory. For did:web, the relay begins serving /.well-known/did.json through the user's custom domain.
426
427The relay holds the signing key and uses it for all commit signing. The rotation key stays on the device (Secure Enclave on iOS, Keychain on macOS) and is only needed for DID recovery/rotation.
428
429**Request Body**
430
431 ----------------------- ---------- -------------- -------------------------------------------------------
432 **Field** **Type** **Required** **Description**
433 signing_pub_key string yes P-256 public key for signing, base64url-encoded
434 rotation_pub_key string yes P-256 public key for DID rotation, base64url-encoded
435 method string no "did:plc" (default) or "did:web" (requires custom domain)
436 handle string no Desired handle (if not already set via POST /v1/handles)
437 recovery_keys array no Additional rotation keys for did:plc recovery
438 ----------------------- ---------- -------------- -------------------------------------------------------
439
440**Response (200 OK)**
441
442 ----------------------- ---------- -------------------------------------------------------
443 **Field** **Type** **Description**
444 did string Fully qualified DID string
445 did_document object W3C DID Document
446 relay_signing_key_id string Identifier of the relay-generated signing key
447 ----------------------- ---------- -------------------------------------------------------
448
449**Error Responses**
450
451 ------------ -------------------------------- -------------------------------------------------------
452 **Status** **Code** **Description**
453 409 ACCOUNT_NOT_FOUND Account does not exist
454 422 INVALID_KEY_FORMAT Public key is malformed or not on supported curve (P-256/secp256k1)
455 422 UNSUPPORTED_DID_METHOD Requested method is not supported or missing required fields
456 429 RATE_LIMITED Too many DID ceremonies initiated
457 ------------ -------------------------------- -------------------------------------------------------
458
459> ***Note:** For did:plc, the relay submits the signed genesis operation to plc.directory and status will be "pending_propagation" until confirmed. For did:web, the relay begins serving /.well-known/did.json through the user's custom domain.*
460
461**GET /v1/dids/:did** [v0.1]
462
463Retrieve the current DID document and resolution status. For did:plc, resolution checks the PLC mirror first, then falls back to upstream.
464
465**Response (200 OK)**
466
467 ------------------- ---------- -----------------------------------------------------------
468 **Field** **Type** **Description**
469 did string Fully qualified DID string
470 did\_document object Current DID document
471 method string \"did:plc\" \| \"did:web\"
472 status string \"active\" \| \"pending\_propagation\" \| \"deactivated\"
473 resolution\_url string Public resolution URL
474 last\_rotated\_at string ISO 8601, last key rotation timestamp
475 mirror\_age\_ms integer For did:plc: staleness of the PLC mirror cache entry
476 ------------------- ---------- -----------------------------------------------------------
477
478**Error Responses**
479
480 ------------ ----------------- ---------------------------------
481 **Status** **Code** **Description**
482 404 DID\_NOT\_FOUND DID doesn't exist in this relay
483 ------------ ----------------- ---------------------------------
484
485**POST /v1/dids/:did/rotate** [v1.0]
486
487Rotate the signing key for a DID. The Tauri app generates a new keypair, signs the rotation operation with the current key, and submits it. Critical for key compromise recovery.
488
489**Request Body**
490
491 ------------------ ---------- -------------- --------------------------------------------------------
492 **Field** **Type** **Required** **Description**
493 new\_public\_key string yes New P-256 (secp256r1) public key, base64url-encoded
494 rotation\_proof string yes Signed proof from the current key authorizing rotation
495 ------------------ ---------- -------------- --------------------------------------------------------
496
497**Response (200 OK)**
498
499 --------------------- ---------- ----------------------------------------
500 **Field** **Type** **Description**
501 did\_document object Updated DID document with new key
502 previous\_key\_hash string Hash of the rotated-out key for audit
503 status string \"active\" \| \"pending\_propagation\"
504 --------------------- ---------- ----------------------------------------
505
506**Error Responses**
507
508 ------------ ------------------------ -------------------------------------
509 **Status** **Code** **Description**
510 400 INVALID\_PROOF Rotation proof signature is invalid
511 409 ROTATION\_IN\_PROGRESS A rotation is already pending
512 ------------ ------------------------ -------------------------------------
513
514> ***Note:** Key rotation also updates the device's registered public key at the relay. For did:plc, the signed rotation operation is submitted to plc.directory. The old key remains valid for challenge-response for a 24-hour grace period to handle in-flight requests.*
515
5165\. Handle & Domain Management
517
518Handle endpoints manage the user's ATProto handle (e.g., alice.yourservice.net or alice.com). Free-tier users get a subdomain on the service domain. Pro users can bring their own domain. The relay automates DNS record provisioning via Cloudflare or Route53.
519
520> ***Note:** Custom domains serve double duty: they are required for both custom handles AND did:web identity. When a Pro user verifies a custom domain, both the handle and the did:web option become available in the setup wizard.*
521
522**POST /v1/handles** [v0.1]
523
524Request a handle. For subdomain handles, the relay provisions DNS immediately. For custom domains, the relay returns required DNS records for the user to configure.
525
526**Request Body**
527
528 ----------- ---------- -------------- --------------------------------------------------------------------------
529 **Field** **Type** **Required** **Description**
530 handle string yes Desired handle (e.g., \"alice\" for subdomain, \"alice.com\" for custom)
531 type string no \"subdomain\" (default) or \"custom\"
532 ----------- ---------- -------------- --------------------------------------------------------------------------
533
534**Response (200 OK)**
535
536 --------------------- ---------- -------------------------------------------------------------
537 **Field** **Type** **Description**
538 handle string Full handle (e.g., \"alice.relay.example.net\")
539 type string \"subdomain\" \| \"custom\"
540 status string \"active\" \| \"pending\_dns\" \| \"pending\_verification\"
541 dns\_records array Required DNS records (for custom domains)
542 verification\_token string TXT record value for domain ownership proof
543 didweb\_eligible boolean Whether this domain unlocks did:web as a DID method option
544 --------------------- ---------- -------------------------------------------------------------
545
546**Error Responses**
547
548 ------------ ------------------ ---------------------------------------------------
549 **Status** **Code** **Description**
550 409 HANDLE\_TAKEN Handle is already in use
551 403 TIER\_RESTRICTED Custom domains require Pro tier
552 422 INVALID\_HANDLE Handle contains invalid characters or is reserved
553 ------------ ------------------ ---------------------------------------------------
554
5555.1 DNS Records Object
556
557For custom domains, the response includes an array of DNS records the user must create:
558
559 -------------- ---------- ------------------------------------------------
560 **Field** **Type** **Description**
561 record\_type string DNS record type: \"CNAME\" \| \"TXT\" \| \"A\"
562 name string Record name (e.g., \"\_atproto.alice.com\")
563 value string Record value
564 ttl integer Recommended TTL in seconds
565 -------------- ---------- ------------------------------------------------
566
567**GET /v1/handles/:handle/status** [v0.1]
568
569Poll DNS propagation and verification status for a handle. The Tauri app and web dashboard poll this endpoint after handle creation.
570
571**Response (200 OK)**
572
573 ----------------- ---------- ---------------------------------------------------------------------------
574 **Field** **Type** **Description**
575 handle string The handle being checked
576 status string \"active\" \| \"pending\_dns\" \| \"pending\_verification\" \| \"failed\"
577 dns\_checks array Per-record check results with pass/fail and last\_checked timestamps
578 verified\_at string ISO 8601, null until verified
579 failure\_reason string Present only if status is \"failed\"
580 ----------------- ---------- ---------------------------------------------------------------------------
581
582**Error Responses**
583
584 ------------ -------------------- ---------------------------------------
585 **Status** **Code** **Description**
586 404 HANDLE\_NOT\_FOUND Handle doesn't exist for this account
587 ------------ -------------------- ---------------------------------------
588
589> ***Note:** For subdomain handles, status transitions directly to \"active\". For custom domains, expect 5--30 minutes for DNS propagation. The relay checks every 60 seconds.*
590
591**DELETE /v1/handles/:handle** [v0.1]
592
593Release a handle. For subdomain handles, the DNS record is removed immediately. For custom domains, the relay stops serving resolution but does not modify the user's DNS.
594
595**Response (200 OK)**
596
597 ----------------- ---------- ---------------------------------------------------------------
598 **Field** **Type** **Description**
599 released\_at string ISO 8601 timestamp
600 cooldown\_until string ISO 8601, handle is reserved for 30 days to prevent squatting
601 ----------------- ---------- ---------------------------------------------------------------
602
603**Error Responses**
604
605 ------------ -------------- -------------------------------
606 **Status** **Code** **Description**
607 401 UNAUTHORIZED Invalid token
608 403 FORBIDDEN Token doesn't own this handle
609 ------------ -------------- -------------------------------
610
6116\. Setup Wizard Flow
612
613The Tauri app includes a first-run setup wizard that guides the user through the complete provisioning flow in a single sitting. The wizard orchestrates the API calls described in sections 2--5 into a linear, non-technical experience.
614
6156.1 Wizard Steps
616
6171. **Enter claim code** --- User copies the 6-character code from the web dashboard. Tauri generates a P-256 keypair and calls POST /v1/devices.
618
6192. **Choose a handle** --- Wizard offers a subdomain handle immediately. If the account has a verified custom domain (Pro tier), the custom domain option also appears. Calls POST /v1/handles.
620
6213. **DID ceremony** --- Wizard pre-selects did:plc. If the user selected a custom domain handle in step 2, a toggle appears offering did:web as an alternative. Wizard prompts for optional recovery key setup (Shamir shares). Calls POST /v1/dids.
622
6234. **Wait for propagation** --- Wizard polls GET /v1/dids/:did and GET /v1/handles/:handle/status until both are active. Shows a progress indicator with estimated time.
624
6255. **Federation handshake** --- Relay calls requestCrawl to the BGS. Wizard confirms the PDS is discoverable on the ATProto network. Displays a \"You're live\" confirmation.
626
6276.2 Failure Recovery
628
629Each wizard step is independently retryable. If the Tauri app loses connection or is closed mid-wizard, it resumes from the last completed step on next launch. The wizard state is persisted locally.
630
631 ---------------------------- ------------------------------------------------------------------------
632 **Failure Point** **Recovery**
633 Claim code expired Generate new code via web dashboard; wizard restarts at step 1
634 Device registration failed Retry with a new claim code
635 Handle taken Wizard prompts for an alternative handle
636 DID propagation timeout Wizard continues polling; user can skip and check later
637 DNS verification timeout Wizard shows manual DNS instructions; polling continues in background
638 Relay unreachable Tauri retries with exponential backoff; wizard shows connection status
639 ---------------------------- ------------------------------------------------------------------------
640
6416.3 BYO Relay Configuration
642
643Self-hosted users who run their own relay can point the Tauri app at their relay before entering the setup wizard. The wizard writes a config file that the app reads on subsequent launches.
644
6456.3.1 Config File
646
647 --------------- ----------------------------
648 **Platform** **Path**
649 macOS / Linux \~/.config/pds/relay.toml
650 Windows %APPDATA%\\pds\\relay.toml
651 --------------- ----------------------------
652
653Minimal config shape:
654
655> \[relay\]
656>
657> url = \"https://my-relay.example.com\"
658>
659> iroh\_node\_id = \"abc123\...\" \# optional, discovered via API
660>
661> \# Auth method is always claim\_code (web dashboard flow)
662
663If the config file exists when the Tauri app launches for the first time, the wizard uses the configured relay URL instead of the default managed service. All subsequent wizard steps (claim code, device binding, DID ceremony) proceed identically.
664
6657\. Exit Ceremony
666
667The exit ceremony allows a user to leave the relay and take their identity and data with them. The DID is the user's identity --- what moves is the PDS hosting, not the identity itself. The follow graph, followers, and all social connections remain intact because the DID never changes.
668
669> ***Design Decision:** The exit story is a sovereignty requirement, not an afterthought. If users can't leave cleanly, the product's sovereignty promise is hollow.*
670
6717.1 Exit Flow
672
6736. **Export repo** --- User downloads a full CAR file of their ATProto repo via the export endpoint. This is the portable data package.
674
6757. **Prepare new PDS** --- User imports the CAR file into their new PDS host (outside the scope of this API, but the export format follows the ATProto spec for com.atproto.sync.getRepo).
676
6778. **Repoint DID** --- For did:plc: user signs a PLC operation updating the service endpoint to their new PDS. The relay submits this to plc.directory. For did:web: user updates their domain's DNS / .well-known/did.json to point to the new PDS. No relay involvement needed.
678
6799. **Grace period** --- The relay continues serving the repo and forwarding requests for 30 days after the DID repoints. This gives the network time to update resolution caches and ensures no dropped interactions during transition.
680
68110. **Account teardown** --- After the grace period (or immediately if the user confirms), the relay purges the repo data, revokes all device tokens, and marks the account as closed.
682
683**GET /v1/export/repo** [v1.0]
684
685Export the full ATProto repo as a CAR (Content Addressable aRchive) file. This is a potentially large download --- the relay streams the response.
686
687**Response (200 OK)**
688
689 --------------------- ---------- --------------------------------------------------
690 **Field** **Type** **Description**
691 Content-Type header application/vnd.ipld.car
692 Content-Disposition header attachment; filename=\"{did}.car\"
693 X-Repo-Rev header The repo revision (commit CID) at time of export
694 --------------------- ---------- --------------------------------------------------
695
696**Error Responses**
697
698 ------------ ---------------------- ----------------------------------------------------
699 **Status** **Code** **Description**
700 401 UNAUTHORIZED Invalid token
701 503 EXPORT\_IN\_PROGRESS Another export is already running for this account
702 ------------ ---------------------- ----------------------------------------------------
703
704> ***Note:** Requires device\_token from the primary device, or session\_token. The response is streamed --- clients should handle large payloads. Recommended: pipe to disk rather than buffering in memory.*
705
706**POST /v1/dids/:did/migrate** [v1.0]
707
708Construct and submit a signed DID operation that repoints the service endpoint to a new PDS. For did:plc, this submits the operation to plc.directory. For did:web, this is a no-op (the user controls their own DNS).
709
710**Request Body**
711
712 ------------------------ ---------- -------------- ------------------------------------------------------------
713 **Field** **Type** **Required** **Description**
714 new\_service\_endpoint string yes URL of the new PDS (e.g., \"https://pds.alice.com\")
715 signing\_proof string yes Proof signed by the device's key authorizing the migration
716 ------------------------ ---------- -------------- ------------------------------------------------------------
717
718**Response (200 OK)**
719
720 ------------------------ ---------- -----------------------------------------------------
721 **Field** **Type** **Description**
722 did string The DID that was migrated
723 new\_service\_endpoint string The endpoint now in the DID document
724 operation\_id string PLC operation ID (for did:plc)
725 status string \"pending\_propagation\" \| \"active\"
726 grace\_period\_ends string ISO 8601, when the relay stops serving the old repo
727 ------------------------ ---------- -----------------------------------------------------
728
729**Error Responses**
730
731 ------------ ------------------------- -------------------------------------------------------------------------
732 **Status** **Code** **Description**
733 400 INVALID\_PROOF Signing proof is invalid
734 400 INVALID\_ENDPOINT New service endpoint is unreachable or malformed
735 409 MIGRATION\_IN\_PROGRESS A migration is already pending for this DID
736 422 DIDWEB\_SELF\_SERVICE did:web migration is handled via user's own DNS; no relay action needed
737 ------------ ------------------------- -------------------------------------------------------------------------
738
739> ***Note:** The relay validates that the new service endpoint is reachable and responds to basic ATProto XRPC calls before submitting the PLC operation. This prevents accidental lockout from typos.*
740
741**DELETE /v1/accounts/:id** [v1.0]
742
743Initiate account teardown. By default, enters a 30-day grace period during which the relay continues serving the repo. The user can force immediate deletion by passing force=true.
744
745**Request Body**
746
747 -------------- ---------- -------------- ----------------------------------------------------------------------------
748 **Field** **Type** **Required** **Description**
749 force boolean no Skip grace period and delete immediately (default: false)
750 confirmation string yes Must be the string \"DELETE {account\_id}\" to prevent accidental deletion
751 -------------- ---------- -------------- ----------------------------------------------------------------------------
752
753**Response (200 OK)**
754
755 --------------------- ---------- --------------------------------------------------------------
756 **Field** **Type** **Description**
757 status string \"grace\_period\" \| \"deleted\"
758 grace\_period\_ends string ISO 8601, when data will be purged (null if force=true)
759 devices\_revoked integer Number of device tokens invalidated
760 data\_purge\_at string ISO 8601, when repo and account data are permanently deleted
761 --------------------- ---------- --------------------------------------------------------------
762
763**Error Responses**
764
765 ------------ ----------------------- ----------------------------------------------------
766 **Status** **Code** **Description**
767 401 UNAUTHORIZED Invalid session token
768 400 INVALID\_CONFIRMATION Confirmation string doesn't match
769 409 ACTIVE\_MIGRATION Cannot delete while a DID migration is in progress
770 ------------ ----------------------- ----------------------------------------------------
771
772> ***Note:** Requires session\_token. During the grace period, the account is read-only: the relay serves existing repo data and DID resolution but rejects new commits. The user can cancel the deletion during this period via POST /v1/accounts/:id/restore.*
773
774**POST /v1/accounts/:id/restore** [v1.0]
775
776Cancel a pending account deletion during the grace period. Restores full write access and re-activates device tokens.
777
778**Response (200 OK)**
779
780 ------------------- ---------- -------------------------------------
781 **Field** **Type** **Description**
782 status string \"active\"
783 devices\_restored integer Number of device tokens reactivated
784 ------------------- ---------- -------------------------------------
785
786**Error Responses**
787
788 ------------ ------------------------ -----------------------------------------------------
789 **Status** **Code** **Description**
790 401 UNAUTHORIZED Invalid session token
791 404 NOT\_IN\_GRACE\_PERIOD Account is not currently in a deletion grace period
792 410 ALREADY\_DELETED Grace period has expired; data has been purged
793 ------------ ------------------------ -----------------------------------------------------
794
7958\. Free Tier Enforcement
796
797Usage is tracked at the relay level and enforced per-account. When a cap is reached, the relay returns 429 on write operations but continues serving reads (eventually consistent). This ensures the PDS remains visible on the network even when the account is over quota.
798
799 ----------------------- --------------- --------------- -----------------------------------------
800 **Resource** **Free Tier** **Pro Tier** **Enforcement**
801 Repo storage 500 MB 50 GB Reject commits over limit
802 Relay bandwidth 2 GB/month 100 GB/month Throttle to 128 kbps over limit
803 XRPC proxied requests 10,000/month 500,000/month 429 on writes, reads continue
804 Devices per account 1 5 Reject new device registrations
805 Custom domains 0 3 Reject handle creation with type=custom
806 ----------------------- --------------- --------------- -----------------------------------------
807
808Approaching-limit warnings are surfaced through the usage endpoint (GET /v1/accounts/:id/usage), via a custom X-Usage-Warning response header when utilization exceeds 80%, and through the Iroh channel as push notifications to the Tauri app.
809
810Critical account events (usage at 90%, device revoked, DID rotation) trigger email notifications to the account's registered email address. This does not require a user-facing event API.
811
8129\. Security Considerations
813
8149.1 Token Security
815
816- Session tokens are JWTs signed with RS256. The relay's public key is published at /.well-known/jwks.json.
817
818- Device tokens are opaque (server-side lookup), not JWTs, to allow instant revocation without token refresh lag.
819
820- Claim codes are cryptographically random, 6-character alphanumeric (36⁶ ≈ 2.2 billion possibilities), rate-limited to 5 attempts per code.
821
8229.2 Key Management
823
824- Device private keys are generated and stored in the OS keychain (macOS Keychain, Windows Credential Manager) via Tauri's secure storage API.
825
826- The relay never sees, transmits, or stores private keys. All cryptographic proofs are generated device-side.
827
828- Key rotation invalidates the previous key after a 24-hour grace period. Rotation events are logged immutably.
829
830- Recovery keys (Shamir shares) are configured during the DID ceremony step of the setup wizard. Loss of all signing keys without recovery shares means the DID is permanently orphaned.
831
8329.3 Transport
833
834- All API traffic is TLS 1.3 only. The relay does not support TLS 1.2 fallback.
835
836- Device-to-relay data sync uses Iroh's encrypted transport (QUIC-based, end-to-end encrypted).
837
838- CORS is restricted to the service's web dashboard origin. The Tauri app uses direct HTTPS, not browser fetch.
839
84010\. Internal Observability
841
842The relay exposes internal metrics for operating the SaaS product. These endpoints are not public-facing --- they are served on a separate internal port (default: 9090) accessible only from the ops network.
843
844> ***Design Decision:** User-facing event streams (webhooks, SSE) are deferred. Device-side notifications use the existing Iroh channel. Email handles critical account events. A public event API will be added if self-hosted operators request it.*
845
84610.1 Infrastructure Metrics
847
848Prometheus-compatible metrics endpoint for standard monitoring infrastructure (Grafana, Datadog, PagerDuty).
849
850> GET :9090/metrics
851
852 ----------------------------------- ----------- ------------------------------------------------
853 **Metric** **Type** **Description**
854 relay\_active\_connections gauge Currently connected Iroh tunnels
855 relay\_request\_duration\_seconds histogram XRPC request latency distribution
856 relay\_error\_total counter Errors by type and status code
857 relay\_buffer\_depth gauge Messages buffered per device (offline devices)
858 relay\_bandwidth\_bytes\_total counter Bytes proxied, labeled by account tier
859 plc\_mirror\_resolution\_ms histogram DID resolution latency from PLC mirror
860 plc\_mirror\_cache\_hit\_ratio gauge PLC mirror cache hit rate
861 iroh\_tunnel\_health gauge Per-tunnel health score (0--1)
862 ----------------------------------- ----------- ------------------------------------------------
863
86410.2 Business Metrics
865
866Higher-level metrics for product health monitoring. Served as JSON on the internal port.
867
868> GET :9090/internal/stats
869
870 ------------------------------------- -------------------------------------------------------------
871 **Metric** **Description**
872 accounts\_by\_tier Count of accounts per tier (free/pro/business)
873 storage\_utilization\_p50\_p95\_p99 Storage consumption distribution across accounts
874 federation\_health requestCrawl success rate, BGS ingestion lag
875 did\_resolution\_latency P50/P95 DID resolution time via PLC mirror vs upstream
876 active\_devices\_24h Devices that connected in the last 24 hours
877 stale\_devices\_72h Devices that haven't connected in 72+ hours (churn signal)
878 exit\_ceremonies\_in\_progress Active DID migrations and account deletions in grace period
879 ------------------------------------- -------------------------------------------------------------
880
881> ***Note:** Business metrics power internal dashboards and customer support tooling. They are not exposed to users. The /internal/stats endpoint requires a separate ops-scoped token and is never routed through the public load balancer.*
882
883## 11. Relay Key Management [v0.1]
884
885The relay holds the ATProto signing key. These endpoints manage the relay's key lifecycle.
886
887**POST /v1/relay/keys** [v0.1]
888
889Generate a new relay signing key. Called during account creation or key rotation. The relay generates the key internally — the private key is never exposed.
890
891**Response (200 OK)**
892
893 ----------------------- ---------- -------------------------------------------------------
894 **Field** **Type** **Description**
895 key_id string Key identifier
896 public_key string P-256 public key, base64url-encoded
897 algorithm string "ES256" (P-256) or "ES256K" (secp256k1)
898 created_at string ISO 8601
899 ----------------------- ---------- -------------------------------------------------------
900
901**DELETE /v1/relay/keys/:keyId** [v1.0]
902
903Revoke a relay signing key. Triggers DID rotation to update the signing key in the DID document.
904
905**Response (200 OK)**
906
907 ----------------------- ---------- -------------------------------------------------------
908 **Field** **Type** **Description**
909 revoked_at string ISO 8601
910 rotation_status string "pending" | "complete"
911 ----------------------- ---------- -------------------------------------------------------
912
913**POST /v1/relay/commits/sign** [v0.2]
914
915Sign an unsigned commit constructed by the desktop PDS. Desktop-enrolled mode only.
916
917**Request Body**
918
919 ----------------------- ---------- -------------- -------------------------------------------------------
920 **Field** **Type** **Required** **Description**
921 unsigned_commit bytes yes CAR-encoded unsigned commit
922 repo_did string yes DID of the repo
923 ----------------------- ---------- -------------- -------------------------------------------------------
924
925**Response (200 OK)**
926
927 ----------------------- ---------- -------------------------------------------------------
928 **Field** **Type** **Description**
929 signed_commit bytes CAR-encoded signed commit
930 commit_cid string CID of the signed commit
931 ----------------------- ---------- -------------------------------------------------------
932
933**GET /v1/relay/repo/snapshot** [v0.2]
934
935Full repo export as CAR file. Used by desktop during initial sync after enrollment.
936
937**Response:** streaming CAR file (same format as com.atproto.sync.getRepo)
938
939**GET /v1/relay/mode** [v0.2]
940
941Current relay operating mode for this account.
942
943**Response (200 OK)**
944
945 ----------------------- ---------- -------------------------------------------------------
946 **Field** **Type** **Description**
947 mode string "mobile-only" | "desktop-enrolled" | "desktop-offline"
948 primary_device string Device ID of repo host (null in mobile-only)
949 signing_key_id string Active signing key identifier
950 ----------------------- ---------- -------------------------------------------------------
951
952## 12. Device Management [v0.2]
953
954Extended device operations for the mobile app. These supplement the existing device registration endpoints in Section 3.
955
956**POST /v1/devices/:id/pair** [v0.2]
957
958Initiate device pairing via QR code. The phone generates a pairing session, the desktop scans the QR code containing the session details.
959
960**Request Body**
961
962 ----------------------- ---------- -------------- -------------------------------------------------------
963 **Field** **Type** **Required** **Description**
964 pairing_code string yes Code from QR scan
965 device_type string yes "desktop" | "mobile"
966 ----------------------- ---------- -------------- -------------------------------------------------------
967
968**Response (200 OK)**
969
970 ----------------------- ---------- -------------------------------------------------------
971 **Field** **Type** **Description**
972 paired_at string ISO 8601
973 device_id string The paired device's ID
974 pairing_status string "paired" | "pending_promotion"
975 ----------------------- ---------- -------------------------------------------------------
976
977**POST /v1/devices/:id/promote** [v0.2]
978
979Promote a paired desktop to repo host. Transitions the relay from mobile-only to desktop-enrolled mode. The relay transfers the repo to the desktop via Iroh.
980
981**Response (200 OK)**
982
983 ----------------------- ---------- -------------------------------------------------------
984 **Field** **Type** **Description**
985 promoted_at string ISO 8601
986 mode string "desktop-enrolled"
987 repo_transfer string "in_progress" | "complete"
988 ----------------------- ---------- -------------------------------------------------------
989
990**GET /v1/devices/:id/status** [v0.2]
991
992Device health and connectivity status.
993
994**Response (200 OK)**
995
996 ----------------------- ---------- -------------------------------------------------------
997 **Field** **Type** **Description**
998 device_id string Device identifier
999 status string "online" | "offline" | "degraded"
1000 last_seen string ISO 8601
1001 is_primary boolean Whether this device hosts the repo
1002 mode string Current lifecycle phase
1003 ----------------------- ---------- -------------------------------------------------------
1004
1005**DELETE /v1/devices/:id** [v0.2]
1006
1007De-enroll a device. Already exists in Section 3. This note confirms mobile app can also call it (not just web dashboard).
1008
1009Note: Update Section 3 to allow device_token auth (not just session_token) for mobile-initiated device removal.
1010
1011## 13. Data Transfer [v0.1]
1012
1013Planned device swap (e.g., upgrading phones). Uses Iroh for direct peer-to-peer transfer with a 6-digit verification code.
1014
1015**POST /v1/transfer/initiate** [v0.1]
1016
1017Generate a transfer session. Returns a 6-digit code for the new device to enter.
1018
1019**Response (200 OK)**
1020
1021 ----------------------- ---------- -------------------------------------------------------
1022 **Field** **Type** **Description**
1023 transfer_id string Transfer session identifier
1024 code string 6-digit verification code
1025 expires_at string ISO 8601 (15 minutes)
1026 iroh_ticket string Iroh connection ticket for direct transfer
1027 ----------------------- ---------- -------------------------------------------------------
1028
1029**POST /v1/transfer/accept** [v0.1]
1030
1031New device submits the transfer code to join the session.
1032
1033**Request Body**
1034
1035 ----------------------- ---------- -------------- -------------------------------------------------------
1036 **Field** **Type** **Required** **Description**
1037 code string yes 6-digit code from old device
1038 device_public_key string yes P-256 public key of new device
1039 ----------------------- ---------- -------------- -------------------------------------------------------
1040
1041**Response (200 OK)**
1042
1043 ----------------------- ---------- -------------------------------------------------------
1044 **Field** **Type** **Description**
1045 transfer_id string Transfer session ID
1046 status string "accepted" | "transferring"
1047 ----------------------- ---------- -------------------------------------------------------
1048
1049**POST /v1/transfer/complete** [v0.1]
1050
1051Finalize the transfer. Old device's token is revoked, new device receives a fresh device_token.
1052
1053**Response (200 OK)**
1054
1055 ----------------------- ---------- -------------------------------------------------------
1056 **Field** **Type** **Description**
1057 new_device_id string New device's identifier
1058 device_token string New device's long-lived token
1059 old_device_revoked boolean Confirmation old token is dead
1060 ----------------------- ---------- -------------------------------------------------------
1061
1062## 14. Recovery [v1.0]
1063
1064Unplanned device loss recovery via Shamir share reconstruction.
1065
1066**POST /v1/recovery/initiate** [v1.0]
1067
1068Begin a recovery ceremony. User must present 2 of 3 Shamir shares to reconstruct the rotation key.
1069
1070**Request Body**
1071
1072 ----------------------- ---------- -------------- -------------------------------------------------------
1073 **Field** **Type** **Required** **Description**
1074 email string yes Account email for verification
1075 share_1 string yes First Shamir share (e.g., from iCloud)
1076 share_source_1 string yes "icloud" | "relay" | "device" | "paper"
1077 ----------------------- ---------- -------------- -------------------------------------------------------
1078
1079**Response (200 OK)**
1080
1081 ----------------------- ---------- -------------------------------------------------------
1082 **Field** **Type** **Description**
1083 recovery_id string Recovery session identifier
1084 shares_needed integer Number of additional shares required
1085 status string "awaiting_shares" | "ready_to_verify"
1086 ----------------------- ---------- -------------------------------------------------------
1087
1088**POST /v1/recovery/verify-key** [v1.0]
1089
1090Submit reconstructed key material to prove DID ownership.
1091
1092**Request Body**
1093
1094 ----------------------- ---------- -------------- -------------------------------------------------------
1095 **Field** **Type** **Required** **Description**
1096 recovery_id string yes Recovery session ID
1097 share_2 string yes Second Shamir share
1098 share_source_2 string yes Source of the second share
1099 ----------------------- ---------- -------------- -------------------------------------------------------
1100
1101**Response (200 OK)**
1102
1103 ----------------------- ---------- -------------------------------------------------------
1104 **Field** **Type** **Description**
1105 status string "verified" | "failed"
1106 rotation_key string Reconstructed rotation public key (for verification)
1107 ----------------------- ---------- -------------------------------------------------------
1108
1109**GET /v1/recovery/restore** [v1.0]
1110
1111Stream the repo and blobs from the relay to the new device after successful key verification.
1112
1113**Response:** streaming CAR file + blob manifest
1114
1115**PUT /v1/keys/shares/:id** [v1.0]
1116
1117Update the relay-held Shamir share (Share 2). Used after key rotation to re-split with new shares.
1118
1119**Request Body**
1120
1121 ----------------------- ---------- -------------- -------------------------------------------------------
1122 **Field** **Type** **Required** **Description**
1123 encrypted_share string yes New encrypted share data
1124 ----------------------- ---------- -------------- -------------------------------------------------------
1125
1126**Response (200 OK)**
1127
1128 ----------------------- ---------- -------------------------------------------------------
1129 **Field** **Type** **Description**
1130 updated_at string ISO 8601
1131 ----------------------- ---------- -------------------------------------------------------
1132
1133**GET /v1/keys/rotation-log** [v1.0]
1134
1135Immutable audit log of all Shamir share rotations and recovery attempts.
1136
1137**Response (200 OK)**
1138
1139 ----------------------- ---------- -------------------------------------------------------
1140 **Field** **Type** **Description**
1141 entries array List of rotation/recovery events with timestamps
1142 ----------------------- ---------- -------------------------------------------------------
1143
1144## 15. Blob Management [v0.1]
1145
1146Blob endpoints follow the ATProto spec. See blob-handling-spec.md for storage architecture and lifecycle details.
1147
1148**POST /v1/blobs/upload** [v0.1]
1149
1150Alias for com.atproto.repo.uploadBlob. Accepts multipart upload, returns CID reference. Subject to per-account storage quotas.
1151
1152Note: This is the same endpoint as the XRPC uploadBlob — listed here for completeness. The provisioning API does not add a separate blob upload path.
1153
1154**GET /v1/accounts/:id/storage** [v0.1]
1155
1156Blob storage usage for an account. Extends the existing usage endpoint with blob-specific metrics.
1157
1158**Response (200 OK)**
1159
1160 ----------------------- ---------- -------------------------------------------------------
1161 **Field** **Type** **Description**
1162 blob_count integer Total blobs stored
1163 blob_bytes integer Total blob storage consumed
1164 blob_limit integer Tier storage limit for blobs
1165 largest_blob integer Size of largest blob (bytes)
1166 ----------------------- ---------- -------------------------------------------------------
1167
1168
1169Appendix A: Status Codes Reference
1170
1171 ---------- --------------------------------------------------------------------------------------------------------------------------------------------- ----------------------------------------
1172 **Code** **Constants** **Context**
1173 400 INVALID\_CLAIM, INVALID\_DOCUMENT, INVALID\_PROOF, INVALID\_ENDPOINT, INVALID\_CONFIRMATION Malformed request or failed validation
1174 401 UNAUTHORIZED, INVALID\_CREDENTIALS, TOKEN\_EXPIRED Authentication failure
1175 403 FORBIDDEN, TIER\_RESTRICTED, DIDWEB\_REQUIRES\_DOMAIN, SINGLE\_DEVICE\_TIER Insufficient permissions or tier
1176 404 NOT\_FOUND, DEVICE\_NOT\_FOUND, DID\_NOT\_FOUND, HANDLE\_NOT\_FOUND, NOT\_IN\_GRACE\_PERIOD Resource doesn't exist
1177 409 ACCOUNT\_EXISTS, DEVICE\_LIMIT, DID\_EXISTS, HANDLE\_TAKEN, ROTATION\_IN\_PROGRESS, LEASE\_HELD, MIGRATION\_IN\_PROGRESS, ACTIVE\_MIGRATION Conflict with existing state
1178 410 ALREADY\_DELETED Resource permanently removed
1179 422 WEAK\_PASSWORD, INVALID\_KEY, INVALID\_HANDLE, KEY\_MISMATCH, DIDWEB\_SELF\_SERVICE Semantic validation failure
1180 423 ACCOUNT\_LOCKED Temporarily locked due to abuse
1181 429 RATE\_LIMITED Rate or usage cap exceeded
1182 503 EXPORT\_IN\_PROGRESS Temporary unavailability
1183 ---------- --------------------------------------------------------------------------------------------------------------------------------------------- ----------------------------------------
1184
1185Appendix B: Design Decisions Log
1186
1187This appendix collects all design decisions made during the specification process, with rationale for future reference.
1188
1189 ------------------------------------------------ --------------------------------------------------------------------------------------------------------------------------------------------------------------
1190 **Decision** **Rationale**
1191 did:plc is the default DID method Decouples identity from relay domain. Clean exit path: user signs a PLC operation to repoint service endpoint. No ongoing liability for the relay operator.
1192 did:web requires a user-owned custom domain Eliminates exit liability. If did:web were offered on service subdomains, the relay would be obligated to host DID documents indefinitely after users leave.
1193 Primary-device write lease for multi-device ATProto repos require a linear commit chain. LWW risks lost writes; conflict queues have poor UX. Primary-device matches actual desktop usage patterns.
1194 Single auth path (web dashboard for all users) One security model to audit. Self-hosted static\_token bypass can be added later if operators request it.
1195 No public event API in v1.0 Iroh channel handles device notifications. Email handles critical alerts. A webhook/SSE surface adds complexity without clear demand.
1196 PLC mirror starts read-only Reduces operational risk. Read-write authority requires consensus participation with the PLC network --- deferred until the product matures.
1197 Setup wizard handles DID ceremony Users get a single \"setup complete\" moment. Splitting device binding and DID ceremony into separate sessions creates drop-off risk.
1198 30-day grace period on account deletion Prevents accidental data loss. The relay continues serving the repo during transition, ensuring zero dropped interactions for the user's followers.
1199 Relay constructs DID document Client sends raw key material, not a pre-built DID document. Ensures relay controls document structure, service endpoints, and signing key binding. Client only needs public keys — no DID assembly logic required. Simplifies mobile clients significantly.
1200 ------------------------------------------------ --------------------------------------------------------------------------------------------------------------------------------------------------------------