# Uniport API

Production base URL: `https://uniport.sh`. The OpenAPI 3.1 schema is at [`/openapi.json`](https://uniport.sh/openapi.json). JSON responses use `Cache-Control: no-store`. Docs errors are `{ "error": "message" }`; Sign errors are `{ "error": { "code": "code", "message": "message" } }`.

## Agent library

[`uniport.mjs`](https://uniport.sh/uniport.mjs) is one ESM file for Node.js 22 or later, built from the app's own modules. `open(link)` returns a handle for the document, note, sheet, invoice, contract, shop, calendar or task list the link points to, and `sign` sends PDFs for signature (see [Uniport Sign](#uniport-sign)). The file encrypts and decrypts in the calling process and sends only the API key, capabilities, `X-Shop-Auth` and ciphertext. It reads the API key from `UNIPORT_API_KEY` (see [API keys](#api-keys)). The [agent guide](https://uniport.sh/llms.txt) lists every method with examples, errors and limits.

```js
import { open } from './uniport.mjs';
const h = await open(process.env.UNIPORT_LINK);   // sends UNIPORT_API_KEY, or { apiKey }
console.log(await h.read());
```

```sh
node uniport.mjs read <link|-> [range]           # - reads the link from UNIPORT_LINK
node uniport.mjs call <link|-> <method> [args…]  # e.g. append, write, products.import
node uniport.mjs methods <link|->
node uniport.mjs create <kind> [options-json]    # document, note, sheet, invoice, contract, shop or tasks
node uniport.mjs discover
node uniport.mjs request METHOD /api/…           # JSON body on stdin
node uniport.mjs mcp                             # the local MCP server, over stdio (see MCP)
```

`request` sends a raw request to `/api/documents`, `/api/draft`, `/api/sign/v1` or `/api/shops`, with the API key from `UNIPORT_API_KEY`. Set `UNIPORT_URL` for a different deployment, `UNIPORT_ACCESS` for a document, shop or Sign capability (or a full document link, or a short link, whose capability it derives), `UNIPORT_SESSION` for email-verified Sign access, and `UNIPORT_SHOP_AUTH` and `UNIPORT_SHOP_GATE` for the `X-Shop-Auth` and `X-Shop-Gate` headers. PDF responses are raw bytes on stdout. Errors go to stderr as `code: message` with a nonzero exit code; the codes are the agent guide's, and the server's own code is the error's `reason`. `UNIPORT_PASSWORD`, or `--password-stdin` with the password piped on stdin, opens shop and owner links that have a password; it is never printed. With a customer link, `orders.price(order)` and `orders.place(order)` price and place an order with the storefront's own pricing and sealing, and `requests.send(request)` sends a contact request. The file doesn't handle account sessions.

For raw work, `UniportClient` sends requests and creates, opens and syncs encrypted Yjs state (`createEncryptedDocument`, which sends the client level the state needs as `minClient`, `openEncryptedDocument`, `syncEncrypted`), and `envelope.seal(key, context, bytes)` and `envelope.open(key, context, envelope)` implement the envelope below. `syncEncrypted` creates a new update UUID on each call; to retry a lost response, resend the exact encrypted update through `request`. The top-level `seal` and `open` of earlier versions are now `envelope.seal` and `envelope.open`, and top-level `open` opens a link. A note's raw state is BlockNote 0.54.2 in the `document` fragment (`blocksToYDoc(editor, blocks, 'document')` from `@blocknote/core/yjs`); the custom table property `layout` is `grid`, `parties` or `signatures`. Notes add the styles `fontSize` (points, such as `14pt`), `fontFamily`, `superscript` and `subscript`; the `tableOfContents` block, with no props or content; `lineSpacing`, `spaceBefore` and `spaceAfter` on text blocks (0 is the default); and `textStyle` (`normal`, `title` or `subtitle`) on paragraphs. Colors are BlockNote color names or `#rrggbb`. A note holding any of these needs client level 2: send `X-Uniport-Client: 2` and raise the file with `POST /api/documents/{documentId}/client-level` `{"level":2}` before syncing that content. The agent library does both. Preserve unknown node properties when editing existing content. A Document's state is described under [Shared Yjs state](#shared-yjs-state).

## Credentials and origins

- An **API key** belongs to a Pro account: `upk_` and 43 base64url characters, made in Settings › API keys. A program sends it as `Authorization: Bearer <key>` on every request to the API (see [API keys](#api-keys)).
- A **document capability** is a 43-character base64url value for one document. A program sends it as `X-Uniport-Capability: <capability>` beside the API key; pages on uniport.sh send it as `Authorization: Bearer <capability>`. Wherever this reference says a capability goes in `Authorization: Bearer`, a program puts it in `X-Uniport-Capability` instead; the same holds for shop, payments and Sign capabilities and the shop backup lookup.
- An **E2EE content key** is a separate 43-character value kept by the client. An encrypted link fragment is `<capability>.<content-key>`. Send only the capability; never send the key.
- A **short link** (`/d#1<secret>`, `/s#1<secret>`) carries one 43-character secret after the digit `1`. The client derives the link's capability from it and sends only that; the secret never goes in a header or body (see [Short links](#short-links)).
- An **account session** is a signed cookie set by `/api/auth`. The browser derives separate login, recovery, and vault-wrapping credentials. Raw passwords and plaintext vault keys are not API inputs.
- A **shop auth value** (`X-Shop-Auth`) is 43 base64url characters the owner's client (the browser or the agent library) derives from the `ownerKey` in the owner link. Owner shop routes need it with the owner capability; the server stores only its hash.
- A **shop gate key** (`X-Shop-Gate`) is 43 base64url characters a browser derives from a shop's password and link key. Customer shop routes of a shop with a password need it; the server stores only its hash.
- A **Sign capability** is a sender or signer token from a private Sign URL fragment. It is unrelated to document access or encryption keys.

If `Origin` is present it must exactly match the request origin. Server and CLI document clients omit it. Account and authentication mutations require a same-origin `Origin` header.

## API keys

Uniport's API is for Pro accounts. A Pro account creates named keys in [Settings › API keys](https://uniport.sh/settings/api-keys), where each key shows once, the list shows its last four characters and when it was last used, and Revoke stops it at once. Plans: [Pricing](https://uniport.sh/pricing). Uniport stores only each key's SHA-256.

A request to `/api/documents`, `/api/links`, `/api/shops`, `/api/pay`, `/api/sign`, `/api/draft`, `/api/send`, `/api/ai` or `/api/calendar` from a program carries the key:

```sh
curl "$UNIPORT_URL/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $UNIPORT_API_KEY" \
  -H "X-Uniport-Capability: $CAPABILITY"
```

- Without a key: `401` `{error, reason: "api-key"}` with `WWW-Authenticate: Bearer realm="Uniport"`. A revoked or unknown key answers the same.
- When Uniport can't check a key for a moment: `503` `{error, reason: "unavailable"}` with `Retry-After: 5`. Send the request again.
- A key of an account not on Pro: `403` `{error, reason: "pro"}`. Its keys work again once the account is on Pro.
- The key opens no file: the capability still decides which file a request reaches and what it may do.
- `uniport.mjs` sends `UNIPORT_API_KEY` only to `https://uniport.sh`, or to `UNIPORT_URL`'s origin when that is set, and only over https (plain http only to this machine). A link to another host gets no key.
- Requests from a browser on uniport.sh, and links people open in a browser (shared links, signing, payment and shop pages), need no key. Payment provider webhooks and returns, Vercel Cron, `/api/sign/v1/health` and a calendar's subscribe feed need none either. The account and authentication routes use the session cookie.
- Routes that act for an account accept its API key in place of the session: Calendar's invites, booking inbox and feed, and the sign inbox's program side. The gate passes the checked key on in `X-Uniport-Api-Key`, and the route checks it again, Pro included, so a forged header opens nothing. An unconfirmed email address still answers `403` `verify_email` where sending needs one.
- [MCP](#mcp) takes the same key, or an OAuth access token.

## Documents

Creation needs no capability (a program sends its API key); every other operation needs a document capability. Content changes only through Yjs sync.

| Method | Path | Access and result |
| --- | --- | --- |
| `POST` | `/api/documents` | Create standard or E2EE; returns `{id,token,role}`. |
| `GET` | `/api/documents/{id}` | Snapshot, version, role, privacy, cursor and encrypted update page. |
| `DELETE` | `/api/documents/{id}` | Owner: delete the file forever, in Trash or not, with its links, history and presence. Returns `{ok:true}`. Refused for a shop's document: use `DELETE /api/shops/{shopId}`. |
| `POST` | `/api/documents/{id}/trash` | Owner: move the file to Trash. Returns `{trashedAt}`. Every link to it then answers 410 with code `trashed` until it is restored; 30 days after `trashedAt` it is deleted forever. Refused (409) for a shop's or a payments page's document. |
| `POST` | `/api/documents/{id}/restore` | Owner: take the file out of Trash, so its links open it again. Returns `{ok:true}`. |
| `POST` | `/api/trash` | `{files:[{id,token}]}`, up to 500 of a client's files with their capabilities: `{trashed:[{id,role,trashedAt}],gone:[id]}`, the ones in Trash (with the role each capability has) and the ones deleted forever. A file is reported gone only to a capability issued for it. 600 per network per hour. |
| `POST` | `/api/documents/{id}/sync` | Standard Yjs sync; view links may publish presence but not content. |
| `POST` | `/api/documents/{id}/encrypted-sync` | E2EE update, presence and compaction relay. |
| `POST` | `/api/documents/{id}/leave` | Any link: remove this link's presence for `{clientId}` now instead of after 20 seconds. |
| `POST` | `/api/documents/{id}/owner-link` | Owner: replace the owner capability; returns `{token}`. The old owner link stops working. Refused for a shop's document. |
| `GET/POST/DELETE` | `/api/documents/{id}/share` | Owner: list, create, or revoke all non-owner links. A calendar takes no new link (`409` `calendar`): it shares through invitations, booking pages and its subscribe link. |
| `GET` | `/api/links` | A short link's capability: the file it opens, `{id,kind,role,envelope}`. |
| `DELETE` | `/api/documents/{id}/share/{linkId}` | Owner: revoke one access-row ID from the share list. |
| `PATCH` | `/api/documents/{id}/share[/{linkId}]` | Owner: `{revoked:true}` ends one link (or, without `linkId`, every non-owner link) at once and returns `{links:[{id,expiresAt}]}`, each with the expiry it had; `{revoked:false,links}` to the file's path brings those same links back with those expiries, within 10 minutes (409 `gone` after). A revoked link is refused everywhere and left out of the list. |
| `GET/POST` | `/api/documents/{id}/history` | Editor/owner: list or create checkpoints. |
| `GET` | `/api/documents/{id}/history/{versionId}` | Editor/owner: read a historical snapshot. |

Creation is limited to 30 requests per IP per hour, explicit checkpoints to 30 per capability per hour (an E2EE file's automatic versions have their own 30), deletes to 10 per capability per hour, moves to and from Trash to 60, and owner-link resets to 10 per document per hour. Per-IP limits use the address Vercel reports; a client-supplied `X-Forwarded-For` header is ignored. Standard state and each decoded update are capped at 2 MB.

### Creation

Standard creation accepts templates `blank`, `welcome`, `meeting`, `brief`, and `sheet` (all notes but the sheet), readable Markdown up to 200,000 characters, or a base64 Yjs `copy`, which is how a Document is created over raw HTTP (with `minClient:3`):

```json
{
  "template": "blank",
  "title": "Mutual non-disclosure agreement",
  "markdown": "# Mutual non-disclosure agreement\n\nEffective date: 2026-09-17",
  "category": "contract",
  "contractType": "mnda",
  "templateVersion": "1.0",
  "contractDetails": {
    "our_legal_name": "Northstar Labs Ltd",
    "their_legal_name": "Example Studio Ltd",
    "effective_date": "2026-09-17",
    "governing_law": "England and Wales"
  }
}
```

With `category:"contract"`, the Yjs `meta` map stores `category`, `contractType`, `templateVersion`, `contractDetails`, and `contractBaseline`. Contract details accept at most 30 strings of up to 5,000 characters.

For E2EE, the client creates the UUID and Yjs document, then encrypts its snapshot locally:

```json
{
  "privacy": "e2ee",
  "id": "0195c6fb-4b12-7ad4-8e1a-ef0b2ff2b8d5",
  "state": "<base64 encrypted envelope>"
}
```

The response contains the owner capability and `privacy:"e2ee"`, never the content key. The client builds `/d/{id}#<capability>.<content-key>` locally.

Both forms also accept a short owner link's `token` and `envelope` (see [Short links](#short-links)); standard creation then also takes the file's `id`, a UUID the client chose, because the envelope is sealed to it. An `id` or `token` already in use, now or ever, returns 409.

E2EE uses AES-256-GCM. The binary envelope is exactly `0x01 || 12-byte IV || ciphertext || 16-byte tag`, encoded as standard base64. UTF-8 AAD is exactly `uniport:v1:<context>`:

| Data | Context |
| --- | --- |
| Snapshot/history | `<documentId>:snapshot` |
| Update | `<documentId>:update:<updateUUID>` |
| Presence | `<documentId>:presence:<clientId>` |
| Local recovery | `<documentId>:local` |
| Account library entry | `library:<userId>:<documentId>` |
| Short link envelope | `link:document:<documentId>` or `link:shop:<shopId>` (lowercase) |

### Synchronization

Standard sync sends `{vector,update?,clientId,awareness?}` with base64 Yjs binary messages and returns `{update,awareness,presence,version,updatedAt,role}`, where `presence[i]` is `{clientId,role}` for `awareness[i]` and `role` is the role of the link that sent it. Apply the response, then clear only updates acknowledged by that completed request. Retain edits made while it was in flight. Yjs updates are idempotent.

Encrypted sync sends:

```json
{
  "cursor": 12,
  "clientId": 123456789,
  "update": {"id":"0195c6fe-e773-7f4b-a436-77d5e352949a","payload":"<encrypted update>"},
  "awareness": "<encrypted awareness>",
  "snapshot": {"version":12,"state":"<encrypted snapshot>"}
}
```

All three payloads are optional. The response has `{updates,cursor,version,role,compacted,awareness}`, where each `awareness` item is `{clientId,awareness,role}`, and may include `snapshot` if the cursor predates compaction. Merge that snapshot first, then updates, and continue until `cursor === version`. Update UUIDs are idempotent while retained. A compaction snapshot is accepted only when its version equals the locked server version. `compacted` is true only when the server accepted the snapshot. Decoded snapshots and update payloads are capped at 2.1 MB and presence at 16 KB. When the outstanding relay log would pass 20 MB the update is refused with status 413 and `code:"log_full"`; send a snapshot, then retry.

Presence is bound to the link that wrote it. An awareness update must describe exactly the sending `clientId` (clients discard any other), each link keeps at most 20 presence rows, and a `clientId` held by a different live link is refused with `presenceRejected:true` in the response; pick a new client ID and retry.

### Shared Yjs state

A note uses the BlockNote `document` XML fragment. Common metadata is in `meta`, comments in `Y.Map("comments")`, and recent activity in `Y.Map("activity")` as `{id,person:{name,color},action,target?,at}`.

A Document has `meta.kind:"document"` and `meta.writer:1`, and keeps its text in the `body` XML fragment as ProseMirror nodes, one block per paragraph, with an `id` attribute on every block. Attributes default to absent, meaning "as the named style sets it".

- Blocks: `paragraph` (`namedStyle`: `title`, `subtitle`, `heading1` to `heading6`, absent for Normal text; `align`: `start`, `center`, `end`, `justify`; `lineSpacing` as a percentage of single; `spaceAbove`, `spaceBelow`, `indentStart`, `indentFirstLine`, `indentEnd` in points; `list` `{id,level}` with level 0–8; `checked`; `keepWithNext`, `keepLinesTogether`, `pageBreakBefore`), `table` > `tableRow` > `tableCell` (`colspan`, `rowspan`, `colwidth` in points, `background`), `pageBreak`, `horizontalRule`, `toc` and `codeBlock`.
- Inline: text with one mark per property (`bold`, `italic`, `underline`, `strike` and `smallCaps` with `on`; `baseline` with `shift` `super` or `sub`; `fontFamily` with `family` and `weight`; `fontSize` with `pt`; `color` and `highlight` with `hex`; `link` with one of `href`, `bookmark` or `heading`; `comment` with `id`), `hardBreak`, `tab`, `footnoteRef` (`id`), `autoText` (`kind` `pageNumber` or `pageCount`) and `image` (`attachment` `uniport-blob:<uuid>`). An inline object stores its marks in attributes named `markBold`, `markFontSize` and so on.
- `Y.Map("hf")` holds header and footer fragments keyed `t0/header/default`, `t0/footer/default`, `t0/header/first` and `t0/footer/first`; `Y.Map("footnotes")` holds footnote fragments keyed by their `footnoteRef` id. `Y.Map("page")` holds `size` (`letter`, `legal`, `tabloid`, `statement`, `executive`, `folio`, `a3`, `a4`, `a5`, `b4`, `b5`), `orientation`, `marginTop`, `marginRight`, `marginBottom`, `marginLeft`, `headerMargin` and `footerMargin` in points, `color` and `mode` (`pages` or `pageless`). `Y.Map("styles")` holds overrides of the named styles, and `Y.Map("lists")` each list's `{preset,start?,levels?}`.
- Comment threads are entries of `Y.Map("comments")` in the notes' shape, plus `anchor:true` on a thread's first comment, whose text carries a `comment` mark with the same id, and `parent:<threadId>` on replies.
- A Document is stored at client level 3: create it with `minClient:3` and send `X-Uniport-Client: 3`; a lower client gets 400 `upgrade`. uniport.mjs builds and edits this state as the editor does.

A sheet file is a workbook. The first sheet (ID `default`) uses the top-level types `cells` (`Y.Map<SheetCell>`), `meta`, `sheetRows`, `sheetColumns`, `sheetDeleted`, `sheetHidden`, `sheetLayouts`, `sheetDimensions`, `sheetMerges`, and `sheetConditional`. Legacy axes are `r0`/`c0`; inserted axes are `r-<UUID>`/`c-<UUID>`. A cell key joins stable IDs; legacy `r0:c0` maps to `0:0`. `sheetDeleted` marks rows and columns removed by a delete; `sheetHidden` marks rows and columns a user has hidden, which keep their data. `sheetMerges` is keyed `rowId|columnId` of the top-left cell using raw axis IDs (`r0|c0`, not the normalized cell key) with value `{end:[rowId,columnId]}`. `meta` holds `rows`, `columns`, `frozenRows` (0–20), `frozenColumns` (0–10), and optionally `filter` and `bandings`.

Each additional sheet has an ID `s-<UUID>` and the same types suffixed with `:<sheetId>`, for example `cells:s-<UUID>`; its `rows`, `columns`, `frozenRows`, `frozenColumns`, `filter` and `bandings` are in `sheetMeta:<sheetId>`. `sheetOrder` (`Y.Array`) lists sheet IDs in tab order; when it is empty the workbook has only `default`. Concurrent inserts can leave duplicate IDs; the first occurrence wins. `sheetTabs` (`Y.Map`) maps a sheet ID to `{name?,color?,deleted?}`; the first sheet's default name is `Sheet1`. Deleting a sheet sets `deleted:true` and keeps its data, so the delete can be undone; formulas that reference it show `#REF!`. `namedRanges` maps a lowercase name to `{name,sheet,start:[rowId,columnId],end:[rowId,columnId]}`, where `sheet` is a sheet ID.

Filters, conditional formatting and alternating colors locate cells with an anchored range, `{start:[rowId,columnId],end:[rowId,columnId]}`, so they follow inserted and deleted rows and columns:

```json
{
  "filter": {
    "range": {"start": ["r0", "c0"], "end": ["r-<UUID>", "c3"]},
    "columns": {"c2": {"hidden": ["Paid", ""], "condition": {"op": "gt", "a": "100"}}}
  },
  "bandings": [
    {"id": "b1", "range": {"start": ["r0", "c0"], "end": ["r9", "c3"]}, "header": true, "colors": {"header": "#bdbdbd", "first": "#ffffff", "second": "#f3f3f3"}}
  ]
}
```

A filter's first row is the header row. Each column filter hides data rows whose displayed text is in `hidden` (`""` means blanks) or that fail `condition`. The filter is shared: filtered rows are hidden for everyone and still count in formulas. `bandings` alternate `first` and `second` below an optional header row; explicit fills and conditional formatting take priority.

`sheetConditional` maps a rule ID to `{id,order,ranges,kind:"single",condition,style}` or `{id,order,ranges,kind:"scale",min,mid?,max}`. Rules apply in ascending `order`, and the first rule that matches a cell sets its style. A cell that fails a rule, or holds no number for a color scale, stays open to later rules. A rule has up to 20 ranges and styles at most 20,000 cells. A condition is `{op,a?,b?}`; operators cover empty, text, number, date and `formula` tests. A `formula` condition is written for the top-left cell of the rule's first range, such as `=$B2>500`, and is evaluated only by conditional formatting; a column filter with a `formula` condition hides every data row. Clients validate these structures when reading them and ignore malformed entries.

`SheetCell` contains `value`, optional formula `layout`, optional `by:{name,color,at}`, and formatting. `pattern` is an Excel/Sheets number format code such as `#,##0.00` or `dd/mm/yyyy`; the legacy `format`, `decimals`, `money` and `border` fields remain readable. Other fields are `valign`, `clip`, `font`, `borders:{top,right,bottom,left}`, and `validation`, a `list`, `checkbox`, `switch`, `number`, `text` or `date` rule. `sheetLayouts` stores `{rows,columns}` snapshots so formula references survive structural edits; a formula that references other sheets also stores `sheets:{sheetId:{rows,columns}}`, so references such as `='Q3 Budget'!A1` follow row and column edits in that sheet. Live selection is awareness state: `sheet:{sheetId,start:[rowId,columnId],end:[rowId,columnId],address}`. Exact shapes are in the OpenAPI `YjsSheetStateGuide`, `SheetCell`, `SheetCellValidation`, `SheetTab`, `SheetNamedRange`, `SheetMerge`, `SheetAnchoredRange`, `SheetFilter`, `SheetColumnFilter`, `SheetCondition`, `SheetConditionalRule`, `SheetConditionalStyle`, `SheetScalePoint` and `SheetBanding` schemas.

Business files are Yjs documents rather than separate CRUD resources. `meta` contains `kind:"invoice"`, `businessType`, and `title`, and sets the PDF's look with `design` (`vienna`, `helvetica-nera`, `tiefletter-gold`, `monoline`, `boutique`, `studio` or `ledger`; absent or unknown prints as `vienna`), `paper` (`a4` or `letter`; absent prints A4) and `logo` (a base64 `data:` URL of a PNG, JPEG or SVG of at most 200 KB and 1,000 px on its long edge, an SVG without text, embedded images or web fonts). `invoice`, `quote`, `credit-note`, and `purchase-order` use `Y.Map("invoice")` and `Y.Array("invoice-items")`; each row has local UUID `id`, `description`, `quantity`, `unitPrice`, `taxRate`, `discountRate`, and `discountFixed`. `receipt` and `remittance` use `Y.Map("payment")` and `Y.Array("payment-allocations")`; each allocation has `id`, `invoiceNumber`, and `amount`. Exact fields are in the OpenAPI `YjsBusinessStateGuide`, `Invoice` and `Payment` schemas.

A contract has `meta.kind:"contract"` and `meta.schema:1`. `meta` also holds `title`, `category:"contract"`, `type` (such as `nda`, `consulting` or `safe-us`), `pack` and `packVersion` (the clause wording it is pinned to), `design` (`helvetica-nera`, `basel`, `folio`, `vienna-legal`, `counsel`, `editorial`, `marrakech`, `chancery`, `atelier`, `gazette` or `brief`), `paper` (`a4`, `letter` or `legal`), `legalProfile` (`uk`, `us`, `singapore`, `global` or null), `status`, `number`, `subtitle` and `logo`. `status` becomes `sent` once signing links are made from the contract's final PDF, recorded by the browser that made them, with `meta.frozen`: what that PDF was set from (`data`, its `/data.json`; `main`, `design`, `date` and `logo`; `state`, the contract's own fields at Sign: its page-setting `meta` keys, `terms`, `parties` and `clauses` rows, with clause text as strings; `reopened`, the `reopenedAt` the contract had then, or null), its SHA-256 (`sha256`, Uniport Sign's `originalHash`), `sentAt` and `sentOn`. Recording the sending puts the contract's fields back to `state`, so an edit made between Sign and the links doesn't stay. A sent contract shows that PDF, reads as `state` (in Details, the agent API and the assistant) and keeps its terms, parties, clauses, design, paper and logo as sent; reopening it (Edit again, or `contract.reopen()`) puts its fields back to `state`, sets `status` back to `draft`, removes `frozen` and sets `reopenedAt`. A sending is recorded only while the contract's `reopenedAt` is still the one its Sign saw, so links from a Sign made before Edit again never send it back. The links already sent keep the wording they carry.

- `Y.Map("contract-terms")` holds the terms by unidocs' names (`effective_date`, `end_date`, `term_months`, `governing_law`, `venue`, `fee_type`, `fee_amount_minor`, `fee_currency`, `fee_schedule`, `purpose`, `mutuality`, `disclosing_side`, `confidentiality_years` and others) and the pack's own variables as strings.
- `Y.Map("contract-parties")` holds `us` and `them`, each a `Y.Map` of `role`, `name`, `legal_name`, `company_no`, `address` (a list of lines), `jurisdiction`, `email`, `attn`, `signer_name`, `signer_title` and `signed_at`.
- `Y.Array("contract-clauses")` holds `Y.Map` rows `{id, slug, order, heading, body}`, where `heading` and `body` are `Y.Text` that keep their `{{variables}}`. `order`, a number, sets the contract order and array position breaks ties; each id and each slug counts once. A licensed form's rows (a SAFE) carry `locked:true` and no text, and print the pack's wording.
- Readers ignore keys they don't know and writers never remove them. A contract is stored at client level 4: create it with `minClient:4` and send `X-Uniport-Client: 4`. uniport.mjs builds and edits this state as the contract editor does. Exact fields are in the OpenAPI `YjsContractStateGuide` schema.

### Sharing and history

Share creation accepts `{role:"edit"|"view",label?,sealedLabel?,accessId?,expiresAt?,token?,envelope?}`; expiry must be future. `token` and `envelope` make a short link (below). The returned `id` is the document ID and `accessId` the new link's access-row ID. The share list returns `{links:[{id,role,label,sealedLabel?,createdAt,lastUsedAt,expiresAt,expired}]}`; `id` is the access-row ID used by individual revocation and renaming.

A standard file's link name is `label`, up to 60 characters. An E2EE file's is `sealedLabel`: the UTF-8 name sealed with the content key and context `<documentId>:link-name:<accessId>`, sent with the `accessId` (a UUID the client chooses) it was sealed to. A taken `accessId` answers 409. An E2EE file refuses a non-empty `label` with 400 `upgrade` (a tab older than sealed names shows its reload text), or 400 `sealed_name` when the User-Agent starts with `uniport-agent/`; a standard file refuses `sealedLabel`, and a body with both answers 400. `POST /api/documents/{id}/share/{linkId}` with `{label}` (standard; `""` removes the name) or `{sealedLabel}` (E2EE; `null` removes it) renames a link, at most 300 times per file per hour, and returns the link list. In an E2EE file, a readable `label` in the list was stored before link names were sealed: seal it and send `{sealedLabel}`, which clears the readable copy. The Uniport app and the agent library do this when the owner opens the file and when they list links.

Owner-link reset accepts an optional `{token,envelope?}`, `token` being 43 base64url characters, so a browser that loses the response can still use the link it chose; the server stores only its hash. With `envelope` the new owner link is a short one; without it any short owner link's envelope is cleared with the old capability. For E2EE the content key is unchanged; append it locally to the new owner capability or share token.

### Short links

A short link is `/d#1<secret>` for a document, sheet, invoice or contract and `/s#1<secret>[~<code>]` for a shop's customer link: 65 characters on `https://uniport.sh`, where the long forms run to 146 and 180. `1` is the format version and `<secret>` 32 random bytes as unpadded base64url. The link grants what the long form with the same role grants.

- **Capability and wrap key.** HKDF-SHA-256 over the 32 secret bytes with salt `uniport:link:v1` gives the capability (info `token`) and the wrap key (info `wrap`), 32 bytes each as unpadded base64url. The capability is an ordinary one: it goes in `Authorization: Bearer` and the server stores its SHA-256 in place of a server-made token.
- **Envelope.** The AES-256-GCM envelope above, sealed under the wrap key with context `link:document:<documentId>` or `link:shop:<shopId>`, at most 4,096 characters. Its plaintext is JSON: `{"v":1}` for a standard file, `{"v":1,"key":<content-key>}` for an E2EE file, and `{"v":1,"key":<docKey>,"pin":<pin>}` for a customer link. A discount code stays in the fragment after `~`, so a code can be added to any link.
- **Creating.** The client picks the secret, derives the capability and seals the envelope, then sends `token` (the capability) and `envelope` with share creation, owner-link reset, document creation or a shop's `POST /links`. A token that any link has ever used, including a revoked, reset or deleted one, is refused with 409, so a link can never be pointed at another file.
- **Opening.** `GET /api/links` with the derived capability returns `{id,kind:"document"|"shop",role,envelope}` for a live short link and 404 for anything else, long links included. The client opens the envelope with the context for that `kind` and `id` and refuses the link if it doesn't open or `v` isn't 1, so a server can't send it to another file or kind. Lookups that find nothing count toward 300 per network per hour; over that, every lookup gets 429.
- **Customer links.** The owner's client derives a customer link's secret with HKDF-SHA-256 over the decoded `ownerKey`, salt `uniport:customer-link:v1` and info `<shopId>:<createdAt>` (the link's `createdAt` in owner state), prefixed with `1`. Owner state keeps the link's capability in `token` as before; a secret whose capability doesn't match `token` means the link is a long one.
- Test vectors: `tests/link-secret.test.ts`.

Standard checkpoint creation accepts `{}`. E2EE requires `{state:"<encrypted current snapshot>"}` using the snapshot context, with `auto:true` for an automatic version the editing client saves (never named). History entries have `kind`: `created`, `checkpoint`, `auto` (standard autosaves, and E2EE versions sent with `auto:true`) or `compaction` (the E2EE snapshot a compaction replaced, stored at most once per 10 minutes). The creation version is always kept, plus the newest 40 checkpoints, 40 autosaves and 12 compaction copies.

## Shops

A shop is an E2EE document whose data lives in `/api/shops`: create the document through `POST /api/documents` with an encrypted placeholder state, then register the shop. Paths are `/api/shops/{shopId}[/{action}[/{item}]]`; longer or unknown paths return 404. Customer routes accept the capability of a live customer link or of the owner link as `Authorization: Bearer`, plus `X-Shop-Gate` when the shop has a password; owner routes need the owner capability and `X-Shop-Auth`. Browsers send every shop request without cookies. Errors are `{error, reason?, salt?}`, where `reason` is `closed`, `full`, `conflict`, `not-a-shop`, `stale`, `registered`, `not-registered`, `photo-limit` or `password`; a `password` error (401 without `X-Shop-Gate`, 403 with a wrong one) carries the shop password's `salt`.

- Customer link: `/s/{shopId}#<capability>.<docKey>.<pin>[~<code>]`, or the short `/s#1<secret>[~<code>]` (see [Short links](#short-links)). The capability and `docKey` are 43 base64url characters, the pin 22, and an optional discount code 10 Crockford base32 characters. Send only the capability.
- Owner link: `/shops/{shopId}#<ownerCapability>.<docKey>.<ownerKey>`, where `ownerKey` is 43 base64url characters, or, with an owner password, the 103-character lock that wraps it (below). Whoever holds it has full owner access. Shops made before owner links carried the owner key have only the first two parts. The owner's library entry stores `<ownerCapability>.<docKey>` as `token`, with `kind:"store"`, `role:"owner"`, `privacy:"e2ee"`, `ownerKey` and, once an owner password has been set, `ownerLock` (the lock, or `""` after removal) and `ownerLockAt` (the ISO time it was last set, changed or removed; a merge keeps the lock with the later time), plus `pad`: spaces, computed as the entry is sealed, that bring the JSON-encoded `title` to 362 UTF-8 bytes and make up for an empty or missing `ownerLock` and a missing `ownerLockAt`, so every shop's entry seals to the same length.

| Method | Path | Access and result |
| --- | --- | --- |
| `GET` | `/api/shops/{shopId}/catalog` | Customer or owner link: `{seq,catalog,open,salt}`; `salt` is the shop password's salt, or null without one, read with the catalog. |
| `POST` | `/api/shops/{shopId}/orders` | Customer or owner link: place a sealed order `{id,key,payload}`, with `{claims,hold,supply}` when the shop counts stock or resells (below). 201 `{ok:true}`; an identical resend returns 200 `{ok:true,duplicate:true}` and takes no stock; 409 `stock` with `left` when an item has too few. |
| `POST` | `/api/shops/{shopId}/requests` | Customer or owner link: send a sealed contact request `{id,key,payload}`. 201 `{ok:true}`; an identical resend returns 200 `{ok:true,duplicate:true}`. |
| `GET` | `/api/shops/{shopId}/photos/{photoId}` | Customer or owner link: the sealed photo, exactly 49,181 bytes of `application/octet-stream` with `Cache-Control: private, max-age=31536000, immutable`. |
| `POST` | `/api/shops/{shopId}` | Owner: register once with `{keys,catalog,state}`. 201; 200 when the same keys and `X-Shop-Auth` register again; 409 `registered` otherwise. |
| `GET` | `/api/shops/{shopId}` | Owner: `{keys,seq,catalog,open,state,stateVersion,gate}`; `gate` is `{salt,key}` with a shop password (`key`: the catalog key sealed under the owner key), else null. |
| `PATCH` | `/api/shops/{shopId}` | Owner: `{open}` pauses or resumes orders; returns `{open}`. |
| `DELETE` | `/api/shops/{shopId}` | Owner: delete the shop with its links, library entries, orders and photos. Returns `{ok:true}`. |
| `PUT` | `/api/shops/{shopId}/catalog` | Owner: publish `{seq,catalog}`, where `seq` is the stored one plus 1. Returns `{seq}`, or 409 `stale`. |
| `PUT` | `/api/shops/{shopId}/state` | Owner: `{version,state}`, where `version` is the one read and `state` is sealed for `version + 1`. Returns `{version}`, or 409 `stale`. |
| `GET` | `/api/shops/{shopId}/orders` | Owner: order metadata, newest first, 200 per page: `{orders:[{id,createdAt,accessId}],total,next}`. Page with `?before=` or `?after=`. |
| `POST` | `/api/shops/{shopId}/orders/read` | Owner: `{ids}` (1–50) returns `{orders:[{id,key,payload}]}` for orders not deleted. |
| `POST` | `/api/shops/{shopId}/orders/delete` | Owner: `{ids}` (1–200), or `{access,since?}` for one link's orders. Returns `{deleted}`. Shop requests send `X-Shop-Client: 2`, the order kinds the client reads (2 adds Uniport Pay link and bank transfer orders; no header is 1); each order records its placer's, and a delete that includes an order placed by a newer client than the asking one is 409 `older`, deleting nothing. |
| `GET` | `/api/shops/{shopId}/requests` | Owner: stored contact requests with their sealed envelopes, newest first, 100 per page: `{requests:[{id,createdAt,key,payload}],total,next}`. Page with `?before=` or `?after=`, as for orders. |
| `DELETE` | `/api/shops/{shopId}/requests/{requestId}` | Owner: delete one contact request. Returns `{ok:true}`. |
| `GET` | `/api/shops/{shopId}/summary` | Owner: `{open,total,lastAt,recent,requests,state,stateVersion,supplyToShip?}` (`supplyToShip`: resellers' orders sent to this shop and not yet shipped or canceled); `recent` holds the newest 100 order arrival times and `requests` the IDs of stored contact requests, newest first. |
| `GET/POST` | `/api/shops/{shopId}/links` | Owner: list every link except the owner's, newest first, as `{links:[{accessId,createdAt,expiresAt}]}`, or create a view link from `{expiresAt,token?,envelope?}` (`expiresAt` future or null; `token` and `envelope` make a short link): 201 `{accessId,token}`. The server stores an empty label. |
| `DELETE` | `/api/shops/{shopId}/links/{accessId}` | Owner: revoke one link other than the owner's, which can't be revoked here. Returns `{ok:true}`. |
| `GET` | `/api/shops/{shopId}/photos` | Owner: `{ids}` of stored photos. |
| `PUT/DELETE` | `/api/shops/{shopId}/photos/{photoId}` | Owner: upload exactly 49,181 bytes as `application/octet-stream` (201 new, 200 identical, 409 `conflict` for other bytes under that ID, 409 `photo-limit` at 330 photos), or delete one. |
| `PUT` | `/api/shops/{shopId}/staged/{photoId}` | Owner: upload a photo sealed under the catalog key a password change moves to, as for `/photos` but staged: customers, the photo list and photo deletes don't see it, and staged rows have their own 330-row cap. |
| `DELETE` | `/api/shops/{shopId}/staged` | Owner: delete staged photos left by a password change that didn't finish. Returns `{ok:true}`. |
| `PUT` | `/api/shops/{shopId}/gate` | Owner: set, change or remove the shop password with `{seq,catalog,gate,photos}`. `gate` is `{salt,verifier,key}` (22 base64url characters, 64 lowercase hex, an 84-character sealed key) or null; `photos` lists the staged photo IDs (at most 100, unique) the new catalog uses. In one statement: the catalog is published at `seq` (the stored one plus 1), the gate is replaced, the listed staged photos become the shop's photos and every other photo row is deleted. Returns `{seq}`, or 409 `stale` with nothing changed when `seq` moved or a listed photo isn't staged. |
| `GET/PUT/DELETE` | `/api/shops/{shopId}/backup` | Owner: `{at}`, when the shop's backup words were made, or null; store new words' backup from `{lookup,backup}` (43 base64url characters and a 192-character sealed backup), which replaces the shop's previous one and returns `{at}`, 409 when another shop holds the lookup; or delete it. 20 new backups per shop per hour. |
| `GET` | `/api/shops/backup` | The lookup derived from a shop's backup words as `Authorization: Bearer`, no shop ID: always 200 `{backup}`, 192 characters, whether a backup is stored under it or not. 30 per network per hour. |

### Shop people

A shop can have managers besides its owner. Each person holds their own capability, key pair and `X-Shop-Auth`; the shop key is sealed to every person for the current `epoch`, and owner state, orders and the archive open with it. Names stay in the sealed owner state. `public/openapi.json` has each body's schema.

| Method | Path | Access and result |
| --- | --- | --- |
| `GET` | `/api/shops/{shopId}/people` | Any person's capability, an unopened link's included: the people, each with role, whether their link is unopened and until when, Make owner, and public key. |
| `POST` | `/api/shops/{shopId}/people` | Owner: register a one-time manager link made in the owner's browser (capability, `X-Shop-Auth` hash, public key, private key sealed under the link's key, the shop key sealed to it). It opens for seven days. |
| `POST` | `/api/shops/{shopId}/claim` | An unopened link swaps itself for the browser's own capability, key pair and `X-Shop-Auth`; the link then answers 404 with reason `used`. |
| `POST` | `/api/shops/{shopId}/leave` | Manager: leave the shop. `POST /back` with the same capability and `X-Shop-Auth` undoes it within ten minutes while the shop key is unchanged. |
| `POST` | `/api/shops/{shopId}/restore/{accessId}` | Owner: bring back a person removed within the last day, with the current shop key sealed to them. |
| `PUT/DELETE` | `/api/shops/{shopId}/taker/{accessId}` | Owner: Make owner (`stay`: the owner stays on as a manager), or cancel it. |
| `POST` | `/api/shops/{shopId}/take` | The person Make owner named becomes the owner in one transaction: a new key ring vouched for by the old signing key, owner state, a catalog without the giver's payment details, and their own capability. Everyone else's access ends unless the giver stays; the backup and the payments page's binding are removed. |
| `POST` | `/api/shops/{shopId}/rotate` | Owner: change the shop key (first person added, a removal with a new order key and catalog, or Reset owner link), resealing the key ring, owner state and password key and sealing the shop key to everyone who stays, checked on the epoch, catalog `seq` and state version. |
| `PUT` | `/api/shops/{shopId}/keys` | Owner or manager: replace the key ring with one holding the same keys and more (the archive key), compare-and-set on the ring and the epoch. |
| `GET/POST` | `/api/shops/{shopId}/archive` | Owner or manager: list the sealed chunks of finished orders (ID, order count, span of arrival times), or store a chunk and delete its orders from the list in the same statement (409 `stale` when one of them is gone). |
| `GET` | `/api/shops/{shopId}/archive/{chunkId}` | Owner or manager: the sealed chunk, opened with an archive key from the key ring. |

A manager's capability and `X-Shop-Auth` also open the owner routes above that run the shop day to day: reading it, pausing orders, the summary, publishing the catalog and state, orders, contact requests, links and photos. Deleting the shop, the shop password, backups, adding people, removals, Make owner and Reset owner link stay the owner's.

Once a shop has people (`epoch` above 0), every owner publish and gate change carries the shop's pay lock hash, and one from a tab that predates a key change is refused with 409 `rekeyed`.

### Stock, suppliers and shipping

Off until a shop turns them on; a shop that doesn't sends none of these. The server sees opaque item tags (16-byte HKDF values from the shop's stock salt and the item, 22 base64url characters), counts and quantities, never names or prices.

- An order may carry `statusHash` (64 hex). An order's `claims` are `[{shop?,tag,qty}]` (at most 30, one per counter, `shop` for a supplier's counter), `hold` is `{kind:'link',link}` (a Uniport Pay link: 30 minutes until paid, up to 24 hours while a payment is pending), `{kind:'bank'}` (3 days until the owner's page shows the order, then 7 days from the order) or `{kind:'none'}` (3 days until the owner's page shows the order, then until the owner acts), and `supply` the sealed supplier parts `[{id,supplier,key,payload,kid?}]`. Claims are taken in one transaction with the order: a counter with too few and no "keep selling" refuses the order with 409 `{reason:'stock',left:{tag:{n,o}}}`.

| Method | Path | Access and result |
| --- | --- | --- |
| `GET` | `/api/shop-stock/{shopId}/left?tags=&from=` | Customer link: what is left per tag, `{left:{tag:{n,o}}}`; `from` reads a supplier's counters for its reseller. Untracked tags are left out. |
| `GET/POST` | `/api/shop-stock/{shopId}/counts` | Owner or manager: `{counts:{tag:{count,oversell,held}}}`; POST `{set:[{tag,seen,count,oversell}],remove:[tag]}` (400 each): with `seen` the server adds `count - seen` to what it holds, so orders placed meanwhile still count. |
| `POST` | `/api/shop-stock/{shopId}/holds` | Owner or manager: `{orders}` returns each order's recorded claims. |
| `POST` | `/api/shop-stock/{shopId}/keep`, `/release`, `/reclaim` | Owner or manager: `{orders}`. Paid keeps the units sold; Cancel releases them; Undo of Cancel takes them again. |
| `POST/GET` | `/api/shop-supply/{supplierId}/resellers` | Supplier owner: make a reseller link `{token?,envelope?,terms,dropship}` (terms sealed under the link's relation key), or list them: `{resellers, past, wholesaleOnly}`, each reseller with `endedBy` and `was` (the sealed terms each Change terms replaced, with when they stopped), `past` the same history for relations no longer listed. An ended relation is listed for 7 days, and for as long as its statement stands. `PUT/DELETE …/resellers/{accessId}` changes the terms or ends the link (the reseller's held stock goes back); `POST …/{accessId}/restore` undoes it within 7 days. A reseller link is ended only here: `DELETE /api/shops/{id}/links/{accessId}` answers 409 `reseller`, and a person's link 409 `person`. |
| `PUT` | `/api/shop-supply/{shopId}/wholesale` | Owner or manager: `{on}` turns Wholesale only on the server on or off; `PUT /api/shops/{id}/catalog` may carry `wholesaleOnly` too. While it is on, every link but the shop's reseller links, its owner's and its people's gets 403 `resellers-only` from the catalog, photos, stock counts, orders, requests and order pay links. |
| `POST/PUT/DELETE` | `/api/shop-supply/{resellerId}/suppliers[/{supplierId}]` | Reseller owner: join with the link's token, report the linked products and "Review orders before sending", or leave (the supplier's held stock goes back). `POST …/suppliers/{supplierId}/restore` undoes a leave within 7 days. |
| `PUT` | `/api/shop-supply/{supplierId}/resellers/{accessId}/statement` | Supplier owner: `{sealed}`, the reseller's statement (what it owes and the bill, sealed under the relation key, exactly 5,500 base64 characters) or `null` to clear it. Kept while the relation is live or ended and not purged; a relation with a statement is kept up to 400 days after it ended. 404 for an unknown relation or one whose reseller shop is gone. |
| `GET` | `/api/shop-supply/{resellerId}/suppliers` | Reseller owner or manager: `{suppliers: [{supplier, accessId, terms, was, statement, statementAt, ended, endedBy, endedAt}]}`, live relations, those ended within 7 days, and those the supplier keeps a statement for (one supplier can have two rows: an ended one still owed and a newer live one). Terms may carry `postage` (per order, absent when none). |
| `GET` | `/api/shop-supply/{supplierId}/orders` | Supplier: released supply orders, newest first, sealed to its inbox. `short` (by counter tag) gives the units a reseller's late Paid found sold to someone else meanwhile; the supplier neither ships nor bills them. `PUT …/orders/{supplyId}/bill` and `…/shipment` post the pay link (sealed under the relation key) and the tracking (sealed under the order's ship key). |
| `GET/POST` | `/api/shop-supply/{resellerId}/sent`, `/release`, `/withdraw`, `/resend` | Reseller: its parts' states (each with `checked`, and `lapsed` when a part held for payment gave its units back unpaid), send held parts (Paid by hand, or after review), withdraw them on Cancel, or reseal stale ones. `POST …/checked` `{parts: [{id, verdict: 'ok'|'mismatch'}]}` records the reseller's check of each part against its order: a paid part goes to the supplier once it is `ok` (or 2 hours after the order when never checked), never when `mismatch`. Supply orders the supplier lists carry `checked` too. |

Held supply parts are released when the order's Uniport Pay link is paid, or when the reseller marks it Paid. Owner state keeps shipments, outside suppliers and reseller terms, sealed as today.

### Order statuses and order emails

Paths are `/api/shop-orders/{shopId}/{action}`, with the same capabilities, `X-Shop-Auth` and `X-Shop-Gate` as `/api/shops`.

| Method | Path | Access and result |
| --- | --- | --- |
| `POST` | `/api/shop-orders/{shopId}/status` | Owner or manager: store orders' sealed statuses (Paid, Ready, Done) by order ID. |
| `GET` | `/api/shop-orders/{shopId}/status?ids=` | Customer link: the sealed statuses of those orders, which the customer's browser opens with the order's status key. |
| `GET` | `/api/shop-orders/{shopId}/status?ids=<one id>` with `X-Order-Token` and no link | The browser that placed the order: its sealed status, once the link it came through was revoked or expired. The order carries `statusHash` (SHA-256 hex of the code) when placed. 404 `closed` for a deleted shop. |
| `GET/POST` | `/api/shop-orders/{shopId}/notify` | Customer link: whether anyone follows the shop's new orders, and tell its followers "New order <reference> in <shop name>" for one new order. |

Owner routes other than registration check, in order: the bearer (401), a live capability (404), the owner role (403), a registered shop (404 `not-registered`), `X-Shop-Auth` present (401) and matching (403). Registration needs the owner capability of an E2EE document and `X-Shop-Auth`, stores catalog `seq` 1 and state version 1, and opens the shop for orders.

Customer routes of a shop with a password check `X-Shop-Gate` after the capability: missing, 401 `password`; limited, 429; wrong, 403 `password`. The server compares the SHA-256 of the header with the stored verifier in constant time. Wrong gate keys count per link (10) and per network (30) per 15 minutes, and the counts are checked before comparing, so a spent limit refuses the right key too.

Placing an order runs: validate the body (400); check the capability (404, or 404 `not-a-shop`); check the gate (401, 429, 403 `password`); if the order ID exists, return 200 for the same key and payload or a deleted order and 409 `conflict` otherwise; refuse a paused shop (409 `closed`); apply the rate limit (429); insert unless the shop holds 2,000 orders or the link 500 (409 `full`). A resend never spends the rate limit. `id` is a UUID, `key` 87 base64url characters decoding to a 65-byte uncompressed P-256 point, and `payload` exactly 21,884 standard base64 characters decoding to 16,413 bytes that start with byte 1.

Sending a contact request runs the same way: validate the body (400); check the capability (404, or 404 `not-a-shop`); check the gate (401, 429, 403 `password`); if the request ID exists, return 200 for the same key and payload and 409 `conflict` otherwise; refuse a paused shop (409 `closed`); apply the rate limits (429); insert unless the shop holds 500 requests (409 `full`). A resend never spends the rate limits. `payload` is exactly 2,772 standard base64 characters decoding to 2,077 bytes that start with byte 1. The server doesn't store which link a request came through. Whether the shop takes requests is in the signed catalog (`reach`), which the server can't read; handled requests are marked in the sealed owner state (`PUT /state`).

Order cursors are `<createdAt>_<id>`. A `next` cursor from an `after` page starts with `~` and continues exactly; a cursor you build also returns orders from the 60 seconds before it, so an order that committed late isn't skipped. Deduplicate by ID. `before` and `after` can't be combined. Deleting keeps only an order's ID and day, so a resend can't store it again; tombstones older than 30 days are purged on a later delete.

Limits: 300 products; 300 photos per catalog and 330 stored per shop, plus 330 staged during a password change; 20 active discount codes; 100 links per shop, owner link included; 2,000 stored orders per shop and 500 per link; 500 stored contact requests per shop; 30 lines per order, quantity 1–99. Requests are capped at 2,200,000 bytes for registration, 1,100,000 for catalog and state, 260,000 for an order (it can carry up to 20 sealed supplier parts) and 4,000 for a contact request. Rate limits: 20 new orders per shop per network (IPv4 address or IPv6 /64) per hour, 10 contact requests per shop per network and 60 per customer link per hour, 30 registrations per network per hour, 600 photo uploads (staged ones included) and 600 publishes (password changes included) per shop per hour, and wrong shop-password gate keys as above.

### Shop encryption

Envelopes are the AES-256-GCM format above. Catalog and state plaintext is `u32be(length) || bytes || zeros` padded to a multiple of 16,384 bytes, 1 to 48 steps (21,884 to 1,048,616 base64 characters). Orders are padded to exactly 16,384 bytes, contact requests to 2,048, photos to 49,152 and discount terms to 192. Photo envelopes travel as raw bytes, the others as standard base64. `keys` is an envelope of at most 2,000 characters holding the signing and inbox private keys as JWK.

| Data | Key | Context |
| --- | --- | --- |
| Signing and inbox private keys | `ownerKey` | `<shopId>:shop-keys:v1` |
| Catalog | `docKey`, or K with a shop password | `<shopId>:shop-catalog:<seq>` |
| Owner state | `ownerKey` | `<shopId>:shop-state:v1:<version>` |
| Order | per-order key | `<shopId>:shop-order:<orderId>` |
| Contact request | per-request key | `<shopId>:shop-request:<requestId>` |
| Discount terms | per-code key | `<shopId>:shop-code:<tag>` |
| Photo | `docKey`, or K with a shop password | `<shopId>:shop-photo:<photoId>` |
| K, for the owner (`gate.key`) | `ownerKey` | `<shopId>:shop-gate-key:v1:<salt>` |
| Owner key in a locked owner link | W | `<shopId>:shop-owner-key:v1` |
| Backup: `0x01`, the shop ID's 16 bytes, owner capability, `docKey`, `ownerKey` (113 bytes) | the backup words' key | `shop-backup:v1:<lookup>` |

`ownerKey` is a random 32-byte key the owner's client creates with the shop, as unpadded base64url. It travels only in the owner link's fragment and, with an account, in the vault-sealed library entry; the server never receives it. Shops created before it derive `ownerKey` with HKDF-SHA-256 over the vault master key, salt `uniport:account:v1` and info `shop-owner:v1:<shopId>` (lowercase), 32 bytes; their owner links gain the key when the owner opens the shop signed in. `X-Shop-Auth` is HKDF-SHA-256 over the decoded `ownerKey` with salt `uniport:account:v1` and info `shop-auth:v1:<shopId>`, encoded the same way; the server stores the SHA-256 of that text at registration.

The catalog plaintext is `{"v":1,"pub":…,"sig":…,"body":…}`: `body` is the catalog JSON as a string, `pub` the 65-byte signing public key and `sig` the 64-byte ECDSA P-256 SHA-256 signature over UTF-8 `uniport:shop-catalog:v1\n<shopId>\n<seq>\n<body>` (`\n` is a newline), both base64url. The pin is the first 16 bytes of SHA-256 over UTF-8 `uniport:shop-pin:v1:<shopId>:` followed by `pub`, as 22 base64url characters. An order's key is HKDF-SHA-256 over the ECDH P-256 shared secret of a fresh key pair and the catalog's `inbox` key, with salt `uniport:shop-order:v1` and info `uniport:shop-order:v1:<shopId>:<orderId>`, a zero byte, the ephemeral public key and the inbox public key; the request's `key` is the ephemeral public key. A contact request's key is made the same way with `shop-request` in place of `shop-order` (salt `uniport:shop-request:v1`, info `uniport:shop-request:v1:<shopId>:<requestId>` and the same keys), and its plaintext is `{"v":1,"id":…,"placedAt":…,"contact":…,"message"?:…}`. The catalog body's `reach` is `request` when the shop takes contact requests; without it the shop takes chat messages when `chat` is set, and neither otherwise. The body's `rules` price every order in order (a `minimum` signed before rules reads as the first rule, subtotal under it → block), and delivery costs `fee` plus `perItem` per item, free from a subtotal of `freeOver`; each order gets the largest discount from a rule or its code, and every fee that applies. An order records a winning rule discount as `ruleDiscount` `{label, percent?}` with `discount` null, and the fees in `totals.fees` `[{label, amount}]`. A discount code's key material is PBKDF2-SHA-256 over the normalized code, 600,000 iterations, salt `uniport:shop-code:v1:<shopId>:` followed by the catalog's 16-byte `codeSalt`; HKDF-SHA-256 with salt `uniport:shop-code:v1` and info `tag` (16 bytes) or `key` (32 bytes) gives the lookup tag and the terms key.

Passwords are NFC-normalised and not trimmed. A shop password's P is PBKDF2-SHA-256, 600,000 iterations, 32 bytes, with salt `uniport:shop-password:v1:<shopId>:` followed by the 16 bytes of `salt`; K is HKDF-SHA-256 over `docKey` (32 bytes) followed by P, with salt `uniport:shop-password:v1` and info `shop-key:v1:<shopId>`; the gate key is HKDF-SHA-256 over K with salt `uniport:shop-gate:v1` and info `<shopId>`, and `verifier` is the SHA-256 hex of its base64url text. An owner password's W is PBKDF2-SHA-256 with the same parameters and salt `uniport:shop-owner-password:v1:<shopId>:` followed by a random 16 bytes; the lock is base64url of those 16 bytes and the 61-byte envelope of the owner key sealed under W. Test vectors are in `tests/fixtures/shop-passwords-v1.json`.

Backup words are 12 BIP-39 English words for 16 random bytes S (the last word carries the first 4 bits of SHA-256(S)). `lookup` and the backup's key are HKDF-SHA-256 over S with salt `uniport:shop-backup:v1` and info `lookup` or `seal`, 32 bytes each, as base64url. The server stores SHA-256 of `lookup` with the backup and returns a 142-byte answer for every lookup, stored or not; an unknown lookup gets the same bytes each time.

## Payments

Uniport Pay takes payments for invoices, quote deposits, shop orders and standalone payment links: cards through the owner's own Stripe account, PayPal into the owner's own PayPal account, USDC or USDT sent straight to the owner's wallets, and bank transfers to the owner's account, which the owner marks received. Uniport never holds or moves the money. Paths are under `/api/pay`; JSON in and out, `Cache-Control: no-store`, errors `{error, code}` where `code` is `not_found`, `invalid`, `auth`, `gone`, `conflict`, `stale`, `rate_limited`, `unavailable`, `method`, `amount` or `upgrade`. Browsers send pay requests without cookies.

- Payments owner link: `/payments/{payeeId}#<capability>.<payeeKey>`. The payee is an E2EE placeholder document (create it through `POST /api/documents` with an encrypted placeholder state, as a shop); the capability is its owner capability. `payeeKey` is 32 random bytes as 43 base64url characters and never reaches the server. Owner requests send `Authorization: Bearer <capability>` and `X-Pay-Auth`: HKDF-SHA-256 over the decoded `payeeKey` with salt `uniport:pay:v1` and info `pay-auth:v1:<payeeId>`, 32 bytes as unpadded base64url. The server stores its SHA-256 and compares in constant time.
- Payment link: `/pay/{linkId}#<k>`. `linkId` is 16 random bytes as 22 base64url characters, chosen by the creating client; `k` is a 32-byte key the server never receives.
- Amounts are minor units of their currency as decimal strings (`"124000"` is 1,240.00 EUR; 0, 2 or 3 decimals by currency) and never JSON numbers. Token amounts are base units (6 decimals) as decimal strings. References are 6 Crockford base32 characters, shown as `K7Q-PDM`.

| Method | Path | Access and result |
| --- | --- | --- |
| `POST` | `/api/pay/payees` | Owner capability of an E2EE document that isn't a shop, plus `X-Pay-Auth`: `{id}` sets up payments. 201 `PayeeView`; the same `X-Pay-Auth` again returns 200; another one 409. |
| `GET/PATCH/DELETE` | `/api/pay/payees/{payeeId}` | Owner: `PayeeView` `{id,createdAt,methods,stripe,paypal,addresses,notify,shops,bankSealed}`; `PATCH {methods?,notifyEmail?,bankSealed?,bankWas?}` (`methods`, any of `card`, `paypal`, `crypto` and `bank`, are what new links offer, in that order; an address gets a 6-digit code, null turns emails off; `bankSealed` is the bank details sealed under `payeeKey`, null removes them; `bankWas` is the `bankSealed` the change was made from, or null for none, and answers 409 `stale` when the payee's are others by now); `DELETE` deletes the placeholder document, its links, payments, events and webhooks. |
| `POST` | `/api/pay/payees/{payeeId}/notify/verify` | Owner: `{code}` confirms the notification address; payment emails start with the next payment. |
| `GET` | `/api/pay/payees/{payeeId}/public` | Anyone: `{id,card,paypal,rails,to}`, what a storefront and a shop manager need. |
| `PUT/DELETE` | `/api/pay/payees/{payeeId}/addresses/{family}` | Owner: `{address,rails,viewKey?,proof?,was?}` sets the wallet for `evm`, `tron`, `solana` or `mobilecoin` and the rails taken there; returns `{family,address,rails}`. `was` (in the body, or `?was=` on `DELETE`) is the address the change was made from, or null for none; 409 `stale` when it has changed. An address another payments profile uses answers 409 with `sign`, the message `Uniport payments profile <payeeId> may receive payments at this wallet.`; send the wallet's signature of it as `proof` (EIP-191 personal_sign for `evm`, TronLink signMessageV2 for `tron`, signMessage for `solana`, as hex or base58). A signed address is removed from profiles that added it without one. |
| `POST/DELETE` | `/api/pay/payees/{payeeId}/connect/{provider}` | Owner: `stripe` or `paypal`. `POST` returns `{url}` to the provider's sign-in and sets a cookie bound to it; `DELETE` disconnects and returns `PayeeView`. |
| `POST` | `/api/pay/payees/{payeeId}/shops` | Owner, plus the shop owner's `X-Shop-Token` (owner capability) and `X-Shop-Auth`: `{shop,methods?}` makes the shop take payments for this payee; `methods` (`card`, `paypal`, `crypto`) are the ways its order links offer (a new binding without them takes none; a rebinding keeps the shop's). |
| `DELETE` | `/api/pay/payees/{payeeId}/shops/{shopId}` | Owner, or the shop owner alone: unbind. |
| `PUT` | `/api/pay/shops/{shopId}/methods` | The shop owner (`X-Shop-Token`, `X-Shop-Auth`): `{methods}` sets which of `card`, `paypal` and `crypto` the shop's order links offer, as Settings → Payments publishes them in the sealed catalog, which the server can't read. Returns `{ok:true,bound}`; `bound` is false when the shop takes no Uniport Pay payments. |
| `POST` | `/api/pay/payees/{payeeId}/check` | Owner: look for this payee's crypto payments and checkouts now; at most once per 15 seconds, later calls answer `{ok:true}` without looking. |
| `GET` | `/api/pay/payees/{payeeId}/links` | Owner: `{links,next}`, newest first, 100 per page; `?status=open\|paid\|all` and `?cursor=` from `next`. Each link adds `keySealed`, `labelSealed`, `shop`, `flags`, `refunded`, `unassigned` crypto transfers, `partPaid: true` when a payer of a link paid more than once has paid part of their quote (the link isn't canceled with `idle` then), and `terms` `{methods,rails,to,bankOf,bankLegacy?}`, what the server holds of the link: `bankOf` is the hash of the payee's `bankSealed` the link's bank details came from, or null for the link's own; `bankLegacy` marks a link made before links said whose their bank details are, which takes bank transfers only while the payee's `bankSealed` has this hash, until `bankOf` is sent; 64 zeros, which no details hash to, mark one whose payee changed after it was made, one an earlier build made after the mark was introduced, or one whose owner's browser can't tell whose its details are, which takes none. |
| `GET` | `/api/pay/payees/{payeeId}/links/{linkId}` | Owner: one link as in the list. |
| `GET` | `/api/pay/payees/{payeeId}/events` | Owner: `{events,cursor}`, oldest first, 100 at most. `?after=evt_<n>` continues from a cursor and `&wait=` (up to 25) waits that many seconds for one; without `after`, the latest 100. |
| `GET/POST` | `/api/pay/payees/{payeeId}/hooks` | Owner: list webhooks, or add one `{url,events?}` (https, at most 5 per payee): 201 with `secret` (`whsec_…`), shown once. |
| `DELETE` | `/api/pay/payees/{payeeId}/hooks/{hookId}` | Owner: remove a webhook. |
| `POST` | `/api/pay/links` | Owner (`payee` in the body): create `{id,payee,amount,currency,reusable?,methods,rails?,to,sealed,keySealed,labelSealed?,expiresAt?,bankOf?}`. `bankOf` is required when `methods` include `bank`: the SHA-256 (hex) of the payee's `bankSealed` when the link's bank details are the payee's (409 `stale` when they aren't the payee's now), and the link then takes bank transfers only while the payee has those details; or null when the details are the link's own. A link with `bank` and no `bankOf` is refused with 400 `upgrade`. 201 `PublicLink`; an identical resend returns 200; any other reuse of the id is 409 `conflict`. `to` must hold the payee's current address for each family of the link's rails, else 409 `stale`. |
| `POST` | `/api/pay/shops/{shopId}/links` | A customer link of a shop bound to a payee (plus `X-Shop-Gate` with a shop password): `{id,amount,currency,to,sealed,rails?,methods?}` for an order; `rails` narrows the payee's rails to those whose address the catalog still signs, and `methods` (`card`, `paypal`, `crypto`) are the shop's in its catalog's order. The link offers only the ways the shop's owner turned on (otherwise all of them), and 400 `method` when none is left. 7 days to pay. A shop's bank transfers go to the bank details in its catalog, without a link. |
| `GET` | `/api/pay/links/{linkId}` | Anyone: `PublicLink` `{id,ref,amount,currency,reusable,status,paid,pending,methods,sealed,expiresAt,paidAt,createdAt,payments}`; `methods` is `{card,paypal,rails,bank,order}`, `order` listing the methods on offer in the owner's order. For a reusable link, `?attempt=<uuid>` scopes `paid`, `pending` and `payments` to that payer. |
| `GET` | `/api/pay/links?ids=a,b` | Anyone: `{links}`, the status of up to 100 links without their descriptions; unknown ids are left out. `shop` names the shop an order link was made for, and `transfer` is true while a bank transfer the payer announced waits to be marked received. `method`, `detail` and `reference` name the latest payment; for a payment marked by the owner, `detail` shows only a common method name such as Bank transfer or Cash and `reference` stays on the owner's views. A link paid more than once shows `paid` and `pending` as 0 and no payment here. With the owner headers and `&payee=<payeeId>`, the owner's own links read as the owner sees them, totals and typed details included, counted against the owner's allowance. |
| `PATCH` | `/api/pay/links/{linkId}` | Owner: `{sealed?,labelSealed?,to?,rails?,expiresAt?,methods?,bankOf?,bankLegacy?}`. A new `sealed` needs `If-Match: "<sha-256 hex of the description it was made from>"` (400 without; 409 `conflict` when the link has another by now). New `to` needs the resealed description. `rails` come with `to` and are checked as a new link's are. `methods` alone keep the link's rails and addresses. `bankOf` is the hash of the payee's `bankSealed`, or null for the link's own details; sending it clears the mark of a link made before `bankOf`. `bankLegacy`, with `bankOf` null, puts that mark back, or with 64 zeros stops bank transfers on the link until `bankOf` is sent; it must be the hash of the payee's `bankSealed` now, or 64 zeros (400 without `bankOf` null, 409 `stale` when it is neither). `X-Pay-Ways: <sha-256 hex of the payee's ways>` answers 409 `stale` when the ways have changed. |
| `POST` | `/api/pay/links/{linkId}/cancel` | Owner: cancel a link that isn't paid. `{idle:true}` cancels only while no payment is on its way (a transfer waiting to be assigned to the link included) and the link has no way to be paid (409 otherwise). |
| `POST` | `/api/pay/links/{linkId}/bank` | Anyone: `{amount?,attempt?}` says a bank transfer to the link's sealed bank details is on its way, for what is still owed: a fixed amount less what the link has paid (on a reusable link, what this `attempt` has paid), or the `amount` given on an open-amount link. It counts nothing until the owner marks the link paid. Only for links whose methods include `bank`. Returns `PublicLink`, whose `payments` list it with status `started`. |
| `POST` | `/api/pay/links/{linkId}/mark` | Owner: `{status:"paid"\|"refunded",amount?,detail?,reference?}` records a payment made elsewhere, such as a bank transfer, or a refund made elsewhere. `payment` names a bank transfer the payer announced (a `started` bank payment in the owner's list), which becomes the payment received; a single link's one announced transfer becomes it without being named, and on a reusable link only the named one does (400 `invalid` for any other id, 409 `conflict` once received). Without `amount`, what is left to pay or to refund, or on a reusable link the named transfer's amount. An `Idempotency-Key` header (8–100 of `A-Z a-z 0-9 - _ . :`) makes a resend record nothing new. |
| `POST` | `/api/pay/links/{linkId}/attach` | Owner: `{chain,tx}` counts a crypto transfer to the payee's address that didn't match by amount. |
| `POST` | `/api/pay/links/{linkId}/refund` | Owner: `{payment,amount?}` refunds a card payment through the owner's Stripe account. |
| `POST` | `/api/pay/links/{linkId}/quote` | Anyone: `{rail,amount?,attempt?}` returns the exact token amount and address to pay, for what is still owed: a fixed amount less what the link has paid (on a reusable link, what this `attempt` has paid other than under this quote), or the `amount` given on an open-amount link. Asked again while nothing was sent to it, the quote keeps its tag and follows what is owed and the exchange rate; a transfer of any amount it asked for with that tag is still its payment, an amount over what it asks now credited as overpaid. |
| `POST` | `/api/pay/links/{linkId}/check` | Anyone: `{rail?,attempt?}` looks for the payment on chain and returns `{link,quotes}`. It looks only for a link with a quote made in the last day, at most once per 5 seconds per link and 20 times a minute per chain across all links; otherwise it returns the current state. |
| `POST` | `/api/pay/links/{linkId}/card` and `/paypal` | Anyone: `{amount?,attempt?}` returns `{url}`, the provider's checkout. |
| `POST` | `/api/pay/links/{linkId}/card/return` and `/paypal/return` | Anyone: `{session}` or `{order}` confirms a checkout the payer returned from; returns `PublicLink`. |
| `GET` | `/api/pay/providers` | Anyone: `{stripe,paypal}`, whether this server can connect an owner's Stripe or PayPal account. |
| `GET` | `/api/pay/fx?currency=GBP` | Anyone: `{currency,rate,at,source}`, USD per unit of the currency as a link's crypto quote takes it now (the ECB, else open.er-api.com, cached an hour). |
| `GET` | `/api/pay/cron` | Vercel Cron with `Authorization: Bearer $CRON_SECRET`, every minute. |

A link's status is `open`, `pending` (a payment seen and not final), `partial`, `paid`, `expired`, `canceled` or `refunded`; reusable links stay `open` until they expire or are canceled. A payment after expiry still counts and flags the link `late`; other flags are `overpaid`, `underpaid`, `wrong-rail`, `amount-match`, `disputed`, `reorged` and `partly-refunded`.

The sealed description is the lib/encryption envelope (AES-256-GCM, additional data `uniport:v1:<context>`) of JSON `{v:1,kind,payee:{name,logo?},title,amount,currency,to,number?,lines?,note?,due?,doc?,bank?,bankOwn?}` under `k` with context `<linkId>:pay-link:v1`, at most 16,384 characters. `amount`, `currency` and `to` repeat the server's, and the pay page refuses a link whose server copy differs. `keySealed` is `k` (32 raw bytes) under `payeeKey` with context `<payeeId>:pay-link-key:<linkId>`, and `labelSealed` the owner's `{v:1,kind,title,doc?}` with context `<payeeId>:pay-label:<linkId>`. `bankOwn` says whose `bank` is: true, the link's own (an invoice's payment details), which stay when the payee's change; false, the payee's, which follow them. `bank` and `bankSealed` hold bank details as an invoice's payment details lines, `[{label,value}]` (at most 12; a line without a label continues the one above); `bankSealed` is that JSON under `payeeKey` with context `<payeeId>:pay-bank:v1`.

Events: `pay.created`, `pay.pending`, `pay.partial`, `pay.paid`, `pay.expired`, `pay.canceled`, `pay.refunded` and `pay.flagged`, each `{id,type,at,payee,link,payment,flag?,late?}` where `link` is the link's status with `shop` and `reusable`, and `payment` names the method, amount, detail and, for crypto, the chain, transaction, sender and token amount. Webhooks get each event as a POST with Standard Webhooks headers, retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 1 day; a webhook that fails for 3 days is turned off. Any 2xx answer counts as delivered. Deliveries never go to private, loopback or link-local addresses. Verify a delivery with the secret:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(secret, headers, body) {        // body: the raw request body as text
  const id = headers['webhook-id'], timestamp = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
  return headers['webhook-signature'].split(' ').some(part => {
    const given = Buffer.from(part.replace(/^v1,/, ''), 'base64');
    return part.startsWith('v1,') && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Limits: amounts up to 999,999,999,999,999 minor units; 30 lines, a 120-character title and a 500-character note per description; 500 order links per shop made in the last hour or with a payment under way; 5 webhooks per payee. Rate limits: 10 payee setups per network per hour, 600 links per payee per hour, 20 order links per shop per network per hour, 600 link reads per network per 10 minutes, 10 checkouts per link per network per 10 minutes, 30 quotes per link per network per 10 minutes and 10 new payers per reusable link per network per hour, 600 owner requests per payee per 10 minutes and 240 event reads per payee per hour.

## AI field drafting

`POST /api/draft` returns structured fields. It accepts no document ID, content, capability, or key.

```json
{"kind":"contract","template":"mnda","prompt":"Mutual NDA between Northstar Labs Ltd and Example Studio Ltd, effective 17 September 2026, for product design, under England and Wales law."}
```

Contract responses are `{fields:{...}}` and omit missing facts. Templates: `mnda`, `nda`, `ncnda`, `consulting`, `service`, `msa`, `sow`, `loan`, `jv`, `share-purchase`, `subscription`, `founders`, `shareholders`, `term-sheet`, `safe-us`, `safe-sg`.

Invoice-family input is `{kind:"invoice",documentType?,prompt}` where type is `invoice`, `quote`, `credit-note`, or `purchase-order`; response is `{invoice:{...}}`. Payment input is `{kind:"payment",documentType,prompt}` where type is `receipt` or `remittance`; response is `{payment:{...}}`. Exact shapes are in OpenAPI. Prompts are 10–8,000 characters. Limits are 6 requests per IP per hour and 150 per day service-wide.

## Calendar

In builds with Calendar (`NEXT_PUBLIC_UNIPORT_CALENDAR=1`); without it every route below answers 404 (the booking cron answers `{"idle":true}`). The calendar itself is an end-to-end encrypted Yjs file: one per account, made by Uniport on the first visit to `/calendar`, synchronized like any encrypted file (client level 6). The routes below hold only what the owner's browser chooses to publish, sealed where it can be.

### Agent library

Calendar settings › Copy calendar link gives the calendar's owner link. `open(link)` returns a calendar handle; `create('calendar')`, `share()` and `delete()` are refused.

`create('tasks', { title, checklist })` makes an encrypted task list from a Markdown checklist (`- [ ] Send quote (due 2026-10-12) @Ana`; indented lines are subtasks), and `open(link)` on a task list returns its handle: read, add, update, move, complete and remove tasks, manage statuses, comment, and export Markdown or CSV. The guide's Task lists section ([llms.txt](https://uniport.sh/llms.txt)) lists every method with an example. Assigning through the library sends no email; the app sends it when a person assigns in the list.

```js
const cal = await open(process.env.UNIPORT_CALENDAR_LINK);
const week = await cal.events({ from: '2026-10-05T00:00:00Z', to: '2026-10-12T00:00:00Z' }); // occurrences, recurring ones expanded
const id = await cal.add({ title: 'Flour call', start: '2026-10-08T14:00', minutes: 30, tz: 'Europe/London' });
await cal.update(id, { place: 'Larder Café' });
await cal.update(seriesId, { start: '2026-10-09T09:30' }, { scope: 'following', occurrence: week[0].original });
await cal.remove(id);
const settings = await cal.settings();                 // zone, weekStart, workHours, defaultMinutes, defaultReminders…
await cal.invite(eventId, { to: ['ana@example.com'] }); // { sent }: the invitation, or an update to guests already invited
const answers = await cal.rsvps({ since });            // [{ eventId, email, name, partstat, at }], set on the events too
const page = await cal.pages.add({ title: 'Intro call', minutes: 30 });   // { id, url }
await cal.bookings.sync();                             // { applied }: bookings made on the pages, as events
const feed = await cal.feed.on();                      // { url }: the subscribe link
```

- `events({from, to})` returns at most 366 days of occurrences: `{ id, key, title, start, end, allDay, recurring, original, event }`. `event(id)` returns one event or `null`.
- `add` and `update` take `title`, `notes`, `place`, `video`, `color`, `busy`, `start` and `end` (an instant with `Z` or an offset, or a wall time read in `tz`), `minutes` (sets the end), `date` and `endDate` (all-day, end exclusive), `tz` (IANA), `rrule` (RFC 5545 without DTSTART; `null` removes it) and `reminders` (`[{minutes, channel: 'email'|'app'}]`).
- On a repeating event, `update` and `remove` take `{scope: 'this'|'following'|'all', occurrence}`, where `occurrence` is the occurrence's `original`.
- `invite(eventId, {to, message})` and `uninvite(eventId, {message})` send the invitation, update or cancellation through the invites route below, with the API key's account as organizer; `rsvps({since})` reads the answers. `pages.list()`, `pages.add({title, minutes, hours, days, buffer, notice, price, about})`, `pages.update(id, patch)` and `pages.remove(id)` publish booking pages; `bookings.sync()` applies their bookings. `feed.get()`, `feed.on()` and `feed.off()` manage the subscribe feed, which each library write refreshes while it is on.
- These use the calendar's account keys, which Calendar's settings seal into the calendar file when they make its link (a map the calendar's end-to-end encryption covers); the server never sees them. A calendar whose link predates this answers `unsupported` with reason `calendar-link` until its owner copies the link again.

### Invitations and answers

| Route | Access |
|---|---|
| `POST /api/calendar/invites` | Signed in, or the account's API key: store the sealed invite and email each guest (`request`) |
| `PATCH /api/calendar/invites/{id}` | The invite's owner, signed in or by API key: store an update or cancellation and email it |
| `GET /api/calendar/invites/{id}` | Open: the sealed snapshot, its sequence and whether it is cancelled |
| `POST /api/calendar/invites/{id}/rsvp` | Open, with the guest's token: a sealed answer; 20 an hour per IP address |
| `GET /api/calendar/invites/rsvps?since=` | Signed in, or the account's API key: answers to this account's invites since a time |

The browser builds the iMIP email (a `text/calendar; method=REQUEST` or `CANCEL` part and an `.ics` attachment) and a snapshot sealed with a per-invite key that only the guests' links carry. The server stores the SHA-256 of each guest's answer token, never an address, title or time, and sends through Send's provider and limits: 120 writes an hour per account and 2,000 open invites. Sending needs a confirmed email address (`verify_email`). Guests answer at `/r/{id}#…` without an account.

### Reminders and the subscribe feed

- `GET /api/calendar/reminders` lists the account's unsent reminder rows; `PUT /api/calendar/reminders` `{upsert: [{id, sendAt, payload}], remove: [id]}` changes up to 200 per call, 1,000 future rows at most. Ids are 43-character blind ids. A payload links to the event and holds its title only when the owner turned on titles in reminder emails.
- `PUT /api/calendar/feed` `{token, ics}` (signed in, or the account's API key) uploads the subscribe feed (titles, times and Busy/Free) and `DELETE /api/calendar/feed` turns it off. `GET /api/calendar/feed/{token}.ics` serves it as `text/calendar` with an ETag, to anyone with the link, without an API key.
- `GET /api/calendar/cron` (every minute, `Authorization: Bearer $CRON_SECRET`) sends due reminders and purges expired invites.

### Booking pages

Paths under `/api/calendar/book`:

| Route | Access |
|---|---|
| `GET pages/{id}` | Open: the sealed snapshot, its sequence and the taken granules |
| `PUT`, `DELETE pages/{id}` | The page's bearer (`Authorization: Bearer <auth>`, derived from the account's calendar keys, or kept on the page record when uniport.mjs made the page; a program sends it in `X-Uniport-Capability` beside the API key) |
| `PUT`, `DELETE pages/{id}/payee` | The page's bearer; the payments profile's capability in the body |
| `POST pages/{id}/bookings` | Open: book a time; 10 an hour per network, 50 a day per page |
| `GET pages/{id}/bookings/{bid}` | The booking's cancel token as bearer |
| `POST pages/{id}/bookings/{bid}/cancel` | The cancel token in the body |
| `GET inbox?pages=`, `POST inbox/processed`, `POST inbox/cancel` | Signed in, or the account's API key: bookings the calendar hasn't applied, marked applied, or cancelled because their locks don't match their sealed time |
| `GET cron` | `Authorization: Bearer $CRON_SECRET`, every minute: confirms paid holds, expires unpaid ones, sends booking emails |

A page publishes its free time as padded 16-byte granule ids (HMACs of 15-minute starts under the calendar's lock secret); a booking names the granules it covers and takes them in one transaction per account, so two bookings never share a time. Bookings are sealed to the calendar's inbox key; the only plaintext is a booking's email job, cleared once sent. Creating a page needs a confirmed email address (403 `verify_email`). Free accounts may publish one page with free bookings (creating a second, or a priced one, answers 402 `pro`); Pro pages may price lengths. `POST /api/pay/bookings/{pageId}/links` makes a priced booking's payment link with the amount, currency and payee the page fixed; the hold lasts 15 minutes.

## Uni

In builds with Uni (`NEXT_PUBLIC_UNIPORT_UNI=1`); every route needs a signed-in session, not an API key.

- `POST /api/ai/agent` streams one model step of a Uni message (SSE, like `/api/ai/turn`): `{runId, messages, toolset: 'read'|'full', final?, plain?}` with content parts `text`, `image_url` (a `data:` URL; 12 a step) and `input_audio` (`wav` or `mp3`), at most 4 MB. It is metered like `/api/ai/turn`: one Uni message counts one request, and file edits it starts (`/api/ai/turn` with `parentRun`, up to 10 a message) count within it.
- `POST /api/ai/fetch` `{url}` returns `{url, title, text, type, bytes}` for a public http(s) page or PDF: 2 MB, 10 seconds and 5 redirects at most, private, loopback and metadata addresses refused at every hop, 60 an hour per account. A refusal is 422 `{error, code}`.
- `POST /api/ai/reads` `{files: [{id, token}]}` (at most 200; `token` is a file's capability, never its key; no session needed) returns `{reads: {id: ISO time}, apps: {id: ISO time}}`: when the assistant, and when an AI app through the remote MCP server's `doc_read`, last read each file whose link still works (`apps`: `docs_documents.app_read_at`, migration 069). A step of `/api/ai/turn` that reached the model records the read for its file (`docs_documents.ai_read_at`, migration 067), as does `POST /api/ai/read` `{id, at?}` (signed in, the capability as Bearer, 240 an hour per link), which Uni's `read_file` sends, a filing sends for a dropped file's kept original and the file made from it, and a file's page sends once on opening with the newest time in the file's record of the assistant's changes. Only the latest time of each is kept.
- `GET /api/ai/sessions` (`?since=` for changes) lists the account's sealed chat heads; `GET /api/ai/sessions/{id}` returns one chat's sealed body; `PUT /api/ai/sessions` stores up to 20 sealed chats (1,200 an hour); `DELETE /api/ai/sessions/{id}` leaves a tombstone, which wins over older copies. The browser seals each chat with the account's vault key; the server never opens them.

## Accounts and authentication

Only these auth routes exist: `POST /api/auth/sign-up/email`, `sign-in/email`, `sign-out`, `revoke-session`, `revoke-sessions`, `revoke-other-sessions`, `update-user`; and `GET /api/auth/get-session`, `list-sessions`. Signup/signin use `{name?,email,password,rememberMe?}` where `password` is the 43-character derived authentication credential; `name` is required for signup. Signup is limited to 10 attempts per IP per hour, signin to 40.

| Method | Account path | Body/result |
| --- | --- | --- |
| `GET` | `/api/account` | `{user,vault}`. |
| `POST` | `/api/account/setup` | `{wrappedKey,recoveryCredential,recoveryWrapped?,recoveryPending?}`; one-time vault setup. |
| `GET` | `/api/account/library` | `{userId,entries:[{id,entry}],next?}`, newest first, 500 a page; a full page has `next`, which `?before=` takes for the page after it. Each page has an `ETag` for the library's version; sent back in `If-None-Match`, an unchanged library answers `304` with no body. |
| `POST` | `/api/account/library` | `{userId,id,token,entry}`; validates the capability and replaces the encrypted entry. |
| `POST` | `/api/account/privacy` | `{privacy:"standard"|"e2ee"}`. |
| `GET/POST` | `/api/account/switches` | `{aiOff,lockdown,canLockdown}`; `POST` `{aiOff?,lockdown?}` turns Uni and AI off or Lockdown on (Pro) or off. |
| `POST` | `/api/account/password` | `{wrappedKey,currentCredential,newCredential}`; revokes other sessions. A `recoveryCredential` from older clients is ignored. |
| `POST` | `/api/account/recovery` | `{currentCredential,recoveryCredential,recoveryWrapped}`; replaces the recovery key and revokes other sessions. |
| `POST` | `/api/account/email` | `{email,currentCredential,newCredential,wrappedKey,recoveryCredential,recoveryWrapped}`, each made for the new email; revokes other sessions. |
| `GET` | `/api/account/delete` | `{deleting,mail,pro}`: whether a delete started, and what goes with the account. |
| `POST` | `/api/account/delete/start` | `{currentCredential}`; ends Pro at once and marks the account as being deleted; the client then deletes what the account owns. Safe to repeat. |
| `POST` | `/api/account/delete` | `{currentCredential}`; deletes the account once `delete/start` ran; `409` before it and while a subscription renews. |
| `POST` | `/api/account/recover/key` | `{email,recoveryCredential}` returns `{recoveryWrapped}` (null for an account whose recovery key is its vault key). |
| `POST` | `/api/account/recover` | `{email,wrappedKey,recoveryCredential,newCredential,vault}`; revokes all sessions. |

`wrappedKey`, `recoveryWrapped` and library `entry` are AES-GCM envelopes. Credential fields are derived 43-character values. `GET /api/account`'s `vault` adds `independentRecovery` (the recovery key is its own key, not the vault key) and `recoveryPending` (it was never shown). Profile name/email are server-visible; file titles, document content keys, and plaintext library entries are not. The password, recovery, email, delete and recover routes share 15 requests per address an hour.

### Compatible account credentials

Normalize email with `trim().toLowerCase()`. Compute a 32-byte SHA-256 salt over UTF-8 `uniport:password:v1:<email>`. Stretch the UTF-8 password with PBKDF2-HMAC-SHA-256, 600,000 iterations, 32 output bytes. Import the result as HKDF input; derive 32 bytes with SHA-256, salt `uniport:account:v1`, and info `authentication` or `vault-wrapping`. Encode the results as unpadded base64url: these are the login credential and wrapping key respectively.

Generate a random 32-byte vault master key. `wrappedKey` encrypts UTF-8 JSON `{"key":"<master>"}` under the wrapping key using context `vault:<normalizedEmail>`. Generate a second random 32-byte key as the recovery key, shown to the person as unpadded base64url. Derive the recovery credential from the decoded recovery key via HKDF-SHA-256 with salt `uniport:account:v1`, info `recovery-authentication`, 32 bytes, unpadded base64url, and its wrapping key with info `uniport:recovery-wrap:v1`; `recoveryWrapped` encrypts `{"key":"<master>"}` under that wrapping key with context `recovery:<normalizedEmail>`. To recover, send `{email,recoveryCredential}` to `/api/account/recover/key`, open the `recoveryWrapped` it returns with the recovery key, and send the new `wrappedKey` with `vault:"recovery-wrap"`. Accounts made before independent recovery keys use the master key as their recovery key: `recoveryWrapped` is null and recovery sends `vault:"recovery-key"`. A `vault` that doesn't match the account answers `409`, so a client never stores the recovery key as the vault key. Library entries encrypt a JSON recent-file record under the master key with context `library:<userId>:<documentId>`. A record includes `id`, `token` (full capability plus content key for E2EE), `title`, `role`, `openedAt` (ISO timestamp), `kind`, and `privacy`.

## Uniport Sign

Sign is exposed at `/api/sign/v1`. Send the sender or signer fragment token as `Authorization: Bearer <sign-capability>`. When email protection applies, send the 12-hour verification token as `X-Sign-Session`. Responses keep a JSON or PDF content type; any other upstream response is returned as `text/plain`.

Programs call `https://uniport.sh/api/sign/v1` with a Pro API key and the Sign capability in `X-Uniport-Capability`. Sign's own origin (`https://sign.uniport.sh/api/v1`) answers only Sign's pages and this proxy; any other caller gets `401` `{"error":{"code":"api_key"}}`, apart from its `health` and `signing-key`.

A signer's link is `https://uniport.sh/sign/request/<id>#<signer token>` and the sender's `https://uniport.sh/sign/review/<id>#<admin token>`; `id` is the request's UUID, and the 43-character token after `#` is the Sign capability. Links made before October 2026, `https://sign.uniport.sh/sign/<id>#<token>` and `https://sign.uniport.sh/review/<id>#<token>`, carry the same id and token.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/sign/v1/health`, `/sample`, `/signing-key` | Health, sample PDF, or Ed25519 public key. |
| `POST` | `/api/sign/v1/envelopes` | Create a draft or active request. |
| `GET/PATCH/DELETE` | `/api/sign/v1/envelopes/{id}` | Read; sender replaces draft fields or permanently deletes. |
| `GET` | `/api/sign/v1/envelopes/{id}/access` | Signer email-verification status. |
| `POST` | `/api/sign/v1/envelopes/{id}/access-code`, `/verify-code` | Send a code or create a 12-hour session. |
| `POST` | `/api/sign/v1/envelopes/{id}/send-link`, `/activate`, `/void` | Sender email, activation, or cancellation. |
| `GET` | `/api/sign/v1/envelopes/{id}/events`, `/receipt` | Audit events or completion receipt. |
| `POST` | `/api/sign/v1/envelopes/{id}/preview`, `/sign` | Preview placement or submit with consent. |
| `GET` | `/api/sign/v1/envelopes/{id}/pdf`, `/progress`, `/completed` | Original, partially signed, or completed PDF. |

Create accepts a base64 flat, unencrypted PDF up to 2 MB/25 pages, 1–5 recipients, and 1–30 fields. Pages are 1-based. Field rectangles use normalized top-left coordinates and must fit inside the page.

```json
{
  "title":"Consulting agreement",
  "pdf":"<base64 PDF>",
  "recipients":[{"id":"client","name":"Alex Example","email":"alex@example.test"}],
  "fields":[{"id":"client_signature","recipientId":"client","type":"signature","page":2,"x":0.55,"y":0.72,"width":0.32,"height":0.09}],
  "accessMode":"before_signing",
  "expiresInDays":7,
  "retentionDays":14,
  "activate":false
}
```

The response contains a private `adminToken` and review URL. Draft fields may be replaced until `/activate` locks them and returns signer links. Access modes: `before_signing` requires an assigned email and verification before access; `after_signing` allows private-link signing and protects the completed copy by email; `link` uses only the private capability.

Read the envelope's exact `consent` text and `originalHash`. The current consent is “I have reviewed this document and agree to sign it electronically. I intend this signature to be my signature on this document.” Preview uses the signing payload without `consent` and records nothing. Final submission must include explicit consent:

```json
{"name":"Alex Example","signature":"data:image/png;base64,<PNG bytes>","method":"draw","documentHash":"<originalHash>","consent":true}
```

`method` is `draw`, `upload`, or `type`; PNG data is capped at 300 KB decoded. Stale hashes, duplicate signatures, signing before activation, and cancelled/expired requests are rejected. The last signature creates the completed PDF and Ed25519 receipt binding original/completed hashes, fields, recipients, access evidence, and events.

Sign statuses: `400` input, `401` missing capability/email session, `403` wrong role, `404` missing request, `409` lifecycle conflict, `410` expired/cancelled, `413` oversized upload, and `429` with `Retry-After`.

### Agent library

`sign` in uniport.mjs runs the sequence above: `sign.send({pdf, title, signers, fields, days, email})` creates the request with a signature and a date per signer where the last page has room (`fields: 'auto'`, the default) or the fields given, activates it and emails each signer's link (`email: false` emails none). It returns `{id, request, status, signers: [{name, email, url, emailed}], notEmailed}`, where `notEmailed` (`[{name, email, why}]`, when someone wasn't emailed) names who wasn't emailed and why, and `request` is the opaque handle `sr1.<id>.<adminToken>` that `sign.status`, `sign.remind`, `sign.cancel`, `sign.pdf` (the completed PDF) and `sign.receipt` take. `sign.list()` reads the requests this machine sent from `~/.config/uniport/sign.json` (mode 0600). The agent guide lists every option and error.

### Sign inbox

A request a program sends also reaches the account's Signatures without the server learning its sender token. The account's browsers derive a P-256 key pair from the vault key (label `sign-inbox-v1`) and publish the public half; a program seals `{id, admin, title, createdAt, status}` to it and posts the envelope; a browser of the account opens it into the account's sealed list of signing requests and deletes the row.

| Route | Access |
|---|---|
| `GET /api/sign/inbox/key` | The account, signed in or by API key: `{key, account}`: the public key (87 base64url characters, an uncompressed point), or `null` until a browser publishes it, and the account's id, which a program tags waiting requests with so they survive an API key rotation |
| `PUT /api/sign/inbox/key` | A browser of the account, signed in, from this site: `{key}`; 60 an hour |
| `POST /api/sign/inbox` | The account, signed in or by API key: one sealed request `{id, key, payload}`; the same row again is a no-op, a different row under a stored id `409` `duplicate`; 200 an hour; `409` `inbox-full` at 500 waiting rows |
| `GET /api/sign/inbox` | A browser of the account, signed in: `{userId, rows}`, rows younger than 30 days, oldest first |
| `DELETE /api/sign/inbox` | A browser of the account, signed in, from this site: `{ids}`, up to 100 |

The seal: ECDH P-256 between a fresh ephemeral key and the inbox key; HKDF-SHA-256 of the shared secret with salt `uniport:sign-inbox:v1` and info `uniport:sign-inbox:v1:<row id>`, a zero byte, the ephemeral key and the inbox key (both raw uncompressed), 32 bytes; AES-256-GCM with a 12-byte IV and additional data `uniport:sign-inbox:v1:<row id>` over the JSON padded to 1,024 bytes. `id` is a random UUID v4, `key` the ephemeral public key (base64url), and `payload` base64url of IV ‖ ciphertext (1,403 characters). Errors are `{error, reason?}`.

## MCP

Uniport has two MCP servers. The agent guide has the install lines for Claude Desktop, Claude Code, claude.ai, ChatGPT, Codex and Cursor.

- **Local**: `node uniport.mjs mcp`, MCP over stdio from the agent library, on the agent's machine. It reads the API key from `UNIPORT_API_KEY` and the links from `~/.config/uniport/links.json` (`UNIPORT_MCP_LINKS` sets another path; mode 0600, refused when others can read it), which `node uniport.mjs mcp add <link|-> --label <label>` (`-` reads `UNIPORT_LINK`; `--password-stdin` a shop's or owner link's password), `mcp list`, `mcp open <label>` (shows the file in the browser) and `mcp remove <label>` manage; `UNIPORT_MCP_ADD` holds links the server stores as it starts, separated by spaces. Its 24 tools cover every kind the library opens and Sign: `files`, `create`, `file` and `guide`, then a tool that reads and tools that change per kind, each taking `file` (a label), `action` (a library method) and `args`. Stored links' keys never leave the process (a link a tool makes to send, such as `share`, returns with its key), and binary results are written to the path in `out` (by default `UNIPORT_MCP_OUT`, else the working directory, else Downloads when that is the root) and returned as `resource_link` file URIs. `out` lies inside that default folder, the working directory, Downloads, Documents or Desktop (`UNIPORT_MCP_OUT_ROOTS` names the folders in their place), and a photo, a logo or a PDF to sign that a tool takes as a path is read only inside the default folder (Downloads when that is the home folder; `UNIPORT_MCP_FILES` names the folders in its place); neither reaches a folder or file whose name starts with a dot, and any other path fails with `forbidden`. [`uniport.mcpb`](https://uniport.sh/uniport.mcpb) packages it for Claude Desktop (MCPB 0.3), with the API key and the links as sensitive settings.
- **Remote**: `POST https://uniport.sh/mcp`, streamable HTTP, stateless. Every tool sets `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`: `sign_send` and `sign_remind` email people outside the account, which can't be recalled, so they are destructive and open-world; `sign_cancel`, `doc_edit` and `file_edit` are destructive; `sign_status`, `sign_pdf`, `doc_read`, `file_read` and `draft_fields` only read. Every tool also lists `securitySchemes: [{ type: 'oauth2', scopes: ['uniport'] }]`: it needs the signed-in account. It holds no keys and serves Sign (`sign_send`, `sign_status`, `sign_remind`, `sign_cancel`, `sign_pdf`), standard documents (`doc_create`, whose `locale` sets A4 or Letter; `doc_read` and `doc_edit`, which also take notes and refuse a link to another kind by name), standard sheets, invoices and contracts (`file_read` and `file_edit`: a `link`, an `action` of that kind's library methods and its `args` in order; a logo as a data: URL) and field drafting (`draft_fields`); a link that carries a key is refused before use (`unsupported`, reason `e2ee`) and not logged. Requests it sends for signature are sealed into the account's sign inbox like the library's, and its resource `uniport://guide` is the agent guide. It lists no files: the account's library is sealed with its vault key and the server keeps only a hash of each link, so an app passes the link it was given.

The remote server answers JSON-RPC 2.0 (protocol versions `2025-11-25`, `2025-06-18` and `2025-03-26`: the client's when supported, else the newest; an `MCP-Protocol-Version` header naming another is answered on `initialize` with the newest, and refused with `400` on any later request). `initialize` names the server as `Uniport`, with its website and its logo as `icons` (PNG at 48, 96, 192 and 512 px under `/brand/`, and `/icon.svg`). Send `Content-Type: application/json` (`415` otherwise) and `Accept: application/json, text/event-stream`; the reply is JSON, or one `message` event for a client that accepts only `text/event-stream`, and notifications alone get `202`. `GET` and `DELETE` answer `405`; an `Mcp-Session-Id` is accepted and ignored. `Origin`, when sent, must be an https website or this machine (`403` for plain http and `null`); the server signs in only by the `Authorization` header, never by a cookie, and no reply allows credentials, so a web page can call it only with a token the person gave that app. Bodies are at most 4 MB (`413`), and an account sends at most 120 messages a minute (`429` with `Retry-After`), each message of a batch counting. A batch holds at most 10 messages, at most one of them a `tools/call` (`400` otherwise, and none of it runs); `sign_send` fetches at most 20 PDFs by `pdf_url` an hour per account. A refusal before a message is read is a JSON-RPC error with `id` null.

```sh
curl https://uniport.sh/mcp \
  -H "Authorization: Bearer $UNIPORT_API_KEY" \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Both servers answer a failed tool call with `isError` and the text `<message> (code: <code>)`. A tool with an `outputSchema` carries `{code, reason}` in `_meta["sh.uniport/error"]`; any other tool returns them as `structuredContent`. Over stdio a batch holds at most 20 messages, run one after another, and `initialize` can't be one of them.

Auth is a Pro account's API key as `Authorization: Bearer <key>`, or an OAuth 2.1 access token sent the same way. Without either the server answers `401` with `WWW-Authenticate: Bearer resource_metadata="https://uniport.sh/.well-known/oauth-protected-resource/mcp"`, plus `error="invalid_token"` for a key or token that was revoked or has expired; an account not on Pro gets `403`, and `503` with `Retry-After` means the key or token couldn't be checked for a moment.

- Protected resource metadata (RFC 9728): [`/.well-known/oauth-protected-resource/mcp`](https://uniport.sh/.well-known/oauth-protected-resource/mcp), naming Uniport as the authorization server.
- Authorization server metadata (RFC 8414): [`/.well-known/oauth-authorization-server`](https://uniport.sh/.well-known/oauth-authorization-server): the authorization, token, registration and revocation endpoints below.
- Client ID Metadata Documents: instead of registering, an app may use the https URL of its own metadata document as `client_id` (no query or fragment, at most 500 characters). Uniport fetches it at authorize (public addresses only, no redirects, 5 KB, 5 seconds) and keeps it for an hour, or a day when it can't be fetched again. The document's `client_id` must be its own URL, with `redirect_uris` as registration takes them and `token_endpoint_auth_method` `none`.
- Every answer returned to `redirect_uri` carries `iss` (RFC 9207), this server's issuer.
- Authorization code with PKCE (`S256` only), the scopes `uniport` and `offline_access` (both always granted), access tokens for 1 hour and refresh tokens for 30 days. A refresh token works once: the pair it came with stops working when the new pair is made. Uniport stores tokens only as their SHA-256. The person signs in to Uniport and allows the app once; Disconnect in Settings › API keys ends one app's tokens and consent (`GET /api/connected-apps` lists the apps and `DELETE /api/connected-apps/{clientId}` disconnects one, from the account's signed-in browser only), and signing out of all devices or deleting the account ends every app's tokens. Disconnect and signing out of all devices also void an authorization code the app has not traded yet: the token endpoint answers `invalid_grant`.

| Route | Use |
| --- | --- |
| `POST /oauth/register` | Dynamic client registration (RFC 7591): `{redirect_uris, client_name?, grant_types?}` returns `201` `{client_id}`. Every app is public: no client secret, `token_endpoint_auth_method` `none`. `redirect_uris` are 1 to 10 https addresses, http on localhost, or the app's own scheme, without commas. 2,000 per network per hour and 10,000 a day in all (`429` `invalid_request` with `Retry-After`); an app with no tokens and no consent is removed after 30 days |
| `GET /oauth/authorize` | In the person's browser: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256` and `state`. It shows Uniport's consent screen, or goes straight back with `code` for an app the account already allowed. Errors return to `redirect_uri` as `error` and `error_description` |
| `POST /oauth/authorize` | The consent screen's own answer, from this site: `{query, allow}` returns `{redirect}` |
| `POST /oauth/token` | A form or JSON. `grant_type=authorization_code` with `code`, `code_verifier`, `redirect_uri` and `client_id`, or `grant_type=refresh_token` with `refresh_token` and `client_id`, returns `{access_token, token_type, expires_in, refresh_token, scope}`. Errors are RFC 6749's `{error, error_description}`: `400`, or `401` for `invalid_client`. When two refreshes send one refresh token, one gets the new pair and the other `invalid_grant`. 60 per app per 10 minutes and 20,000 per network (`429` `invalid_request` with `Retry-After`) |
| `POST /oauth/revoke` | Token revocation (RFC 7009), a form or JSON: `token` (an access or refresh token), `token_type_hint?` and `client_id`. The access and refresh token issued together both stop working; the app stays allowed. `200` whether or not the token was valid; a token issued to another app is left alone. `400` `invalid_request` without `token` |

## Health

`GET /api/health` returns `{ok:true,service:"uniport-docs",privacyModes:["e2ee","standard"]}` when its database is reachable, otherwise `503` with `{ok:false}`.
