This is the full developer documentation for HDTP # HDTP documentation > An address your AI agent can be reached at, by other people's agents, with you deciding what each of them may do. The protocol Identity as a certificate you hold, contacts as vCards, per-contact permissions, sealed envelopes. [Start with the protocol](/protocol/), or read the [specification](https://hdtp.io/spec/). HDTP Gateway The self-hosted node: one Go binary. [Quickstart](/gateway/quickstart/), [how-to guides](/gateway/how-to/invite-someone/), [operations](/gateway/operations/) and its [owner MCP tools](/gateway/owner-mcp/). BatonDeck The hosted platform. The [/v1 API reference](/cloud/api/), the [owner MCP server](/cloud/mcp/) and [webhooks](/cloud/webhooks/). For AI agents `llms.txt`, every page as markdown, and WebMCP tools in every page. [How to use them](/agents/). [The /v1 OpenAPI document](/batondeck-openapi.json)BatonDeck's API, as the route table declares it (OpenAPI 3.1). # For AI agents > How an agent reads these docs — llms.txt, every page as markdown, and WebMCP tools in the page. Everything on this site is written for people and served for agents too. None of it needs a key. ## llms.txt | File | What it holds | | ------------------------------------------------------------ | ----------------------------------------------------- | | [`/llms.txt`](/llms.txt) | the index: what HDTP is and a link to each set below | | [`/llms-full.txt`](/llms-full.txt) | every page, in full, in one file | | [`/llms-small.txt`](/llms-small.txt) | every page, with the non-essential parts left out | | [`/_llms-txt/hdtp-gateway.txt`](/_llms-txt/hdtp-gateway.txt) | the HDTP Gateway pages | | [`/_llms-txt/batondeck.txt`](/_llms-txt/batondeck.txt) | the BatonDeck pages, the whole /v1 reference included | ## Any page as markdown Every page is also served as markdown, in two ways: * add `.md` to its path, without the trailing slash: [`/gateway/quickstart.md`](/gateway/quickstart.md); * or ask for it: `curl -H 'Accept: text/markdown' https://docs.hdtp.io/gateway/quickstart/`. Under every page’s title, **Copy page as Markdown** puts that same text on the clipboard, ready to paste into a chat with a model, and **View as Markdown** opens it. The BatonDeck API is also here as its [OpenAPI 3.1 document](/batondeck-openapi.json). ## Tools in the page (WebMCP) Every page of this site registers five read-only tools with the browser through [WebMCP](https://github.com/webmachinelearning/webmcp): an agent working in the browser can call them on the page it is on, with no server of ours in the loop. They run in the page, over files this site serves: the pages’ markdown (`/_docs/pages.json`), the [OpenAPI document](/batondeck-openapi.json) and the two owner-MCP references (`/reference/batondeck-mcp.json`, `/reference/hdtp-gateway-owner-mcp.json`). Each file is fetched from this site the first time a tool needs it. | Tool | What it does | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search_docs` | ranks sections of every page against a query and answers the best, with their links | | `read_page` | one page, as markdown, by its path (`/cloud/mcp/`) | | `list_pages` | every page’s path, title and description, optionally only under one path | | `get_api_operation` | one BatonDeck `/v1` operation by its operationId (`listApiKeys`), as JSON | | `get_mcp_tool` | one owner-MCP tool by name: a BatonDeck router (`batondeck_inbox_read`), one of its expanded actions (`batondeck_inbox_read__list`), or a node tool (`send_to_contact`), as JSON | A browser without WebMCP gets the same page with nothing registered. In Chrome 154 and 156, WebMCP is behind a flag (`--enable-features=WebMCP`, measured 2026-09-30); the page registers on `document.modelContext` only. The tools are about the documentation. The servers that act on HDTP are the products’ own: a node’s [owner MCP](/gateway/owner-mcp/) and BatonDeck’s [owner MCP](/cloud/mcp/). # HDTP > HDTP in five lines, and where the specification, its MUSTs and its test vectors are published. The normative text of the protocol is published at [hdtp.io/spec](https://hdtp.io/spec/), with every MUST listed at [/spec/musts](https://hdtp.io/spec/musts/) and the test vectors at [/spec/vectors](https://hdtp.io/spec/vectors/). What follows is the protocol repository’s own summary. 1. **Identity** — a self-signed root certificate you hold, pinned by its fingerprint. The host you choose serves you under a leaf your root issued for its address, valid for as long as you choose up to 398 days; contacts learn each renewed leaf from the chain, carried once and named by fingerprint after. Every call is mTLS with the leaf key. 2. **Contacts** — standard vCards with `X-HDTP-CERT`; shared over channels people already use; always mutual, always human-approved. 3. **Invites** — short URLs/QRs whose settings (expiry, max uses, auto-accept, preset) live server-side, so they’re revocable at the protocol level. 4. **Capabilities** — everything a contact may do is an MCP tool, filtered per caller via `tools/list`; new integrations are just new tools. 5. **Delivery** — direct HTTPS, always; there is no relay. A person who must be reachable while their own machine is off is hosted by a provider under a leaf they issued and can leave (§9). ## Learn the protocol The guided pages on hdtp.io, one per topic: * [Certificates and renewal](https://hdtp.io/learn/certificates-and-renewal/) * [Contacts and invites](https://hdtp.io/learn/contacts-and-invites/) * [HDTP, MCP and A2A](https://hdtp.io/learn/hdtp-mcp-and-a2a/) * [Hosting and moving](https://hdtp.io/learn/hosting-and-moving/) * [Identity: root, leaf, passkey](https://hdtp.io/learn/identity/) * [Messages and threads](https://hdtp.io/learn/messages-and-threads/) * [Permissions and tiers](https://hdtp.io/learn/permissions-and-tiers/) * [Sealed envelopes](https://hdtp.io/learn/sealed-envelopes/) * [Security model and trade-offs](https://hdtp.io/learn/security-model/) * [What is HDTP](https://hdtp.io/learn/what-is-hdtp/) # BatonDeck > The hosted HDTP platform: the portal, the /v1 API and the owner MCP server, and where each answers. BatonDeck runs HDTP identities for people who would rather not run a node. Three doors reach the same platform, each answering on its own host in production: | Door | Where | For | | ----------------------------------- | ------------------------------- | ------------------------------------------- | | The portal | `https://app.batondeck.com` | a person, signed in with the portal session | | [The /v1 API](/cloud/api/) | `https://api.batondeck.com/v1` | a program, with a workspace API key | | [The owner MCP server](/cloud/mcp/) | `https://mcp.batondeck.com/mcp` | an agent, over OAuth | Every action the owner MCP takes is a `/v1` operation run through the same pipeline, with the same permission; the [MCP reference](/cloud/mcp/) names the operation behind each action. ## Keys A workspace API key: `bd__`. Minted by a person on the portal’s Agents page (Agent keys), which calls `POST /v1/workspace/keys` with a session; a key cannot mint one, because minting needs step-up. Shown once, hashed at rest. Malformed, unknown and revoked keys answer identically. # The /v1 API > BatonDeck's REST API: 109 operations, generated from the route table the router mounts. The API the BatonDeck portal is built on, and the one a program talks to. Two credentials. A browser sends the portal session cookie and, on anything that changes state, must be same-origin. A program sends `Authorization: Bearer ` — a workspace API key, minted in the portal, shown once. A key is never wider than the owner who minted it: its scopes narrow their permissions and can never widen them. A key has no session, so it has no step-up: every operation marked as requiring one is refused to a key, whatever scopes it carries. Those are the irreversible ones — export, deletion, key rotation, changing who is in the workspace — and a person has to be present for them. Webhooks. A workspace registers an endpoint with `POST /v1/workspace/webhooks`, naming the events it wants, and lists its endpoints with `GET /v1/workspace/webhooks`, which also answers the event names a filter may use. Each delivery is a JSON POST to the endpoint, signed in `X-BatonDeck-Signature: t=,v1=." under the endpoint's secret>` (two `v1` values while a rotated secret is still accepted), and carrying `X-BatonDeck-Event-Id`, `X-BatonDeck-Event-Type` and `X-BatonDeck-Delivery`. Nothing is delivered while the workspace’s `webhooks_enabled` flag is off; the list says which it is, as `enabled`. **Server:** `https://api.batondeck.com` · **OpenAPI:** [3.1.0 document](/batondeck-openapi.json) (109 operations; the same document the API serves at `https://api.batondeck.com/v1/openapi.json`). ## Credentials | Scheme | How it is sent | Notes | | --------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session` | cookie `__Host-bd_session` | | | `apiKey` | `Authorization: Bearer …` | A workspace API key: `bd__`. Minted by a person on the portal’s Agents page (Agent keys), which calls `POST /v1/workspace/keys` with a session; a key cannot mint one, because minting needs step-up. Shown once, hashed at rest. Malformed, unknown and revoked keys answer identically. | ## Errors Every refusal answers this body: ```json { "type": "object", "properties": { "error": { "type": "object", "properties": { "code": { "description": "a stable machine-readable code; HDTP §12 names where a call maps to one", "type": "string" }, "message": { "description": "what went wrong, in words a person can act on", "type": "string" }, "request_id": { "description": "the same id that appears in our logs for this request", "type": "string" }, "retry_after": { "description": "on a 429: seconds until the window closes, as the Retry-After header says", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 } }, "required": [ "code", "message", "request_id" ], "additionalProperties": false } }, "required": [ "error" ], "additionalProperties": false } ``` ## Calling it from a browser The API answers cross-origin (CORS) requests only on `/mcp`, `/oauth/token`, `/oauth/register`, `/oauth/revoke`, `/.well-known/`. `/v1` is not among them, so a page on another origin — this one included — cannot call it, and this reference has no in-page “try it”. Each operation shows the `curl` call instead. ## Operations * [Factors](/cloud/api/factors/) — 4 operations * [Identities](/cloud/api/identities/) — 3 operations * [Identity: address](/cloud/api/identity-address/) — 1 operation * [Identity: addresses](/cloud/api/identity-addresses/) — 3 operations * [Identity: audit](/cloud/api/identity-audit/) — 1 operation * [Identity: badges](/cloud/api/identity-badges/) — 1 operation * [Identity: card](/cloud/api/identity-card/) — 1 operation * [Identity: certificate](/cloud/api/identity-certificate/) — 1 operation * [Identity: changes](/cloud/api/identity-changes/) — 1 operation * [Identity: contacts](/cloud/api/identity-contacts/) — 14 operations * [Identity: csr](/cloud/api/identity-csr/) — 1 operation * [Identity: digest](/cloud/api/identity-digest/) — 1 operation * [Identity: export](/cloud/api/identity-export/) — 2 operations * [Identity: grants](/cloud/api/identity-grants/) — 3 operations * [Identity: import](/cloud/api/identity-import/) — 5 operations * [Identity: inbox](/cloud/api/identity-inbox/) — 1 operation * [Identity: integrations](/cloud/api/identity-integrations/) — 6 operations * [Identity: invites](/cloud/api/identity-invites/) — 4 operations * [Identity: leaf](/cloud/api/identity-leaf/) — 1 operation * [Identity: media](/cloud/api/identity-media/) — 1 operation * [Identity: messages](/cloud/api/identity-messages/) — 2 operations * [Identity: pending](/cloud/api/identity-pending/) — 2 operations * [Identity: presets](/cloud/api/identity-presets/) — 2 operations * [Identity: settings](/cloud/api/identity-settings/) — 2 operations * [Identity: storage](/cloud/api/identity-storage/) — 1 operation * [Identity: threads](/cloud/api/identity-threads/) — 4 operations * [Identity: wallet request](/cloud/api/identity-wallet-request/) — 2 operations * [Integrations](/cloud/api/integrations/) — 1 operation * [Presets](/cloud/api/presets/) — 1 operation * [Sessions](/cloud/api/sessions/) — 3 operations * [Statuses](/cloud/api/statuses/) — 1 operation * [Workspace](/cloud/api/workspace/) — 2 operations * [Workspace: audit](/cloud/api/workspace-audit/) — 1 operation * [Workspace: billing](/cloud/api/workspace-billing/) — 3 operations * [Workspace: deletion](/cloud/api/workspace-deletion/) — 2 operations * [Workspace: domains](/cloud/api/workspace-domains/) — 5 operations * [Workspace: export](/cloud/api/workspace-export/) — 1 operation * [Workspace: keys](/cloud/api/workspace-keys/) — 3 operations * [Workspace: members](/cloud/api/workspace-members/) — 1 operation * [Workspace: pause](/cloud/api/workspace-pause/) — 2 operations * [Workspace: plan](/cloud/api/workspace-plan/) — 1 operation * [Workspace: residency](/cloud/api/workspace-residency/) — 1 operation * [Workspace: settings](/cloud/api/workspace-settings/) — 1 operation * [Workspace: sso](/cloud/api/workspace-sso/) — 3 operations * [Workspace: usage](/cloud/api/workspace-usage/) — 1 operation * [Workspace: webhooks](/cloud/api/workspace-webhooks/) — 6 operations # Factors > The caller's own second factors: whether two-factor authentication is on, and each authenticator; Start adding an authenticator app: the QR code and secret, shown once, and the enrolment its first code finishes. Nothing changes until then; Finish adding an authenticator with the code it shows: from ## The caller’s own second factors: whether two-factor authentication is on, and each authenticator `GET /v1/factors` · operation `listFactors` Requires the `batondeck:workspace:read` permission (action `factor:list`). **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------ | | 200 | The caller’s own second factors: whether two-factor authentication is on, and each authenticator | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "enabled": { "type": "boolean" }, "factors": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "type": "string" }, "created_at": { "type": "string" } }, "required": [ "id", "type", "created_at" ], "additionalProperties": false } } }, "required": [ "enabled", "factors" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/factors' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Start adding an authenticator app: the QR code and secret, shown once, and the enrolment its first code finishes. Nothing changes until then `POST /v1/factors` · operation `startFactorEnrolment` Requires the `batondeck:workspace:read` permission (action `factor:enroll`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Start adding an authenticator app: the QR code and secret, shown once, and the enrolment its first code finishes. Nothing changes until then | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "enrolment": { "type": "string" }, "secret": { "type": "string" }, "uri": { "type": "string" }, "qr_code": { "type": "string" }, "expires_at": { "type": "number" } }, "required": [ "enrolment", "secret", "uri", "qr_code", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/factors' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Finish adding an authenticator with the code it shows: from then on, signing in asks for it `POST /v1/factors/verify` · operation `verifyFactorEnrolment` Requires the `batondeck:workspace:read` permission (action `factor:enroll`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------- | ------ | -------- | ----------------------- | | `enrolment` | string | yes | ≥ 1 chars, ≤ 2048 chars | | `code` | string | yes | | Request schema ```json { "type": "object", "properties": { "enrolment": { "type": "string", "minLength": 1, "maxLength": 2048 }, "code": { "type": "string", "pattern": "^\\d{6}$" } }, "required": [ "enrolment", "code" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------- | | 201 | Finish adding an authenticator with the code it shows: from then on, signing in asks for it | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "factor": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "type": "string" }, "created_at": { "type": "string" } }, "required": [ "id", "type", "created_at" ], "additionalProperties": false } }, "required": [ "factor" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/factors/verify' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Remove one of the caller’s authenticators; with none left, signing in asks for no code `DELETE /v1/factors/{id}` · operation `removeFactor` Requires the `batondeck:workspace:read` permission (action `factor:remove`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/factors/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identities > The identities in the signed-in workspace; One identity: its key fingerprint, hostname and card; Erase one identity: its object, its media, and its routing row ## The identities in the signed-in workspace `GET /v1/identities` · operation `listIdentities` Requires the `batondeck:identities:read` permission (action `identity:list`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | The identities in the signed-in workspace | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "identities": { "type": "array", "items": { "type": "object", "properties": { "slug": { "type": "string" }, "account_id": { "type": "string" }, "fingerprint": { "type": "string" }, "status": { "type": "string" }, "hostname": { "type": "string" }, "certified": { "type": "boolean" }, "created_at": { "type": "number" } }, "required": [ "slug", "account_id", "fingerprint", "status", "hostname", "certified", "created_at" ], "additionalProperties": false } } }, "required": [ "identities" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## One identity: its key fingerprint, hostname and card `GET /v1/identities/{slug}` · operation `getIdentity` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------- | | 200 | One identity: its key fingerprint, hostname and card | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "slug": { "type": "string" }, "account_id": { "type": "string" }, "fingerprint": { "type": "string" }, "status": { "type": "string" }, "hostname": { "type": "string" }, "certified": { "type": "boolean" }, "created_at": { "type": "number" }, "card": { "type": "string" } }, "required": [ "slug", "account_id", "fingerprint", "status", "hostname", "certified", "created_at", "card" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Erase one identity: its object, its media, and its routing row `DELETE /v1/identities/{slug}` · operation `deleteIdentity` Requires the `batondeck:identities:manage` permission (action `identity:delete`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: address > Switch the identity to the address its current leaf names: the routing half of a move (SPEC §5.3, §9) ## Switch the identity to the address its current leaf names: the routing half of a move (SPEC §5.3, §9) `POST /v1/identities/{slug}/address` · operation `moveIdentityAddress` Requires the `batondeck:identities:manage` permission (action `identity:move`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | --------- | ------ | -------- | ---------------------- | | `address` | string | yes | ≥ 3 chars, ≤ 300 chars | Request schema ```json { "type": "object", "properties": { "address": { "type": "string", "minLength": 3, "maxLength": 300 } }, "required": [ "address" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------- | | 201 | Switch the identity to the address its current leaf names: the routing half of a move (SPEC §5.3, §9) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "address": { "type": "string" }, "from": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "vacated_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "address", "from", "vacated_until" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/address' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: addresses > Contacts waiting at a new address for the owner's decision (SPEC §5.3); Re-pin the contact at the new address, as accept_new_hosts: auto would have; Leave the pin where it is; the new address is a stranger the owner may block ## Contacts waiting at a new address for the owner’s decision (SPEC §5.3) `GET /v1/identities/{slug}/addresses/pending` · operation `listPendingAddresses` Requires the `batondeck:contacts:read` permission (action `contact:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------- | | 200 | Contacts waiting at a new address for the owner’s decision (SPEC §5.3) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "pending": { "type": "array", "items": { "type": "object", "properties": { "root": { "type": "string" }, "endpoint": { "type": "string" }, "current_endpoint": { "type": "string" }, "why": { "type": "string" }, "at": { "type": "number" }, "display_name": { "type": "string" } }, "required": [ "root", "endpoint", "current_endpoint", "why", "at", "display_name" ], "additionalProperties": false } } }, "required": [ "pending" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/addresses/pending' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Re-pin the contact at the new address, as accept_new_hosts: auto would have `POST /v1/identities/{slug}/addresses/{root}/approve` · operation `approvePendingAddress` Requires the `batondeck:contacts:manage` permission (action `address:decide`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `root` | path | string | yes | | **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------- | | 200 | Re-pin the contact at the new address, as accept_new_hosts: auto would have | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "root": { "type": "string" }, "endpoint": { "type": "string" } }, "required": [ "root", "endpoint" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/addresses/:root/approve' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Leave the pin where it is; the new address is a stranger the owner may block `POST /v1/identities/{slug}/addresses/{root}/reject` · operation `rejectPendingAddress` Requires the `batondeck:contacts:manage` permission (action `address:decide`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `root` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------- | | 200 | Leave the pin where it is; the new address is a stranger the owner may block | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "root": { "type": "string" } }, "required": [ "root" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/addresses/:root/reject' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: audit > The identity's own audit chain, newest first ## The identity’s own audit chain, newest first `GET /v1/identities/{slug}/audit` · operation `listIdentityAudit` Requires the `batondeck:audit:read` permission (action `audit:identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------------- | ----- | ------- | -------- | ---------------------------- | | `slug` | path | string | yes | | | `limit` | query | integer | yes | | | `subject` | query | string | | | | `action_like` | query | string | | only actions containing this | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | The identity’s own audit chain, newest first | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "rows": { "type": "array", "items": { "type": "object", "properties": { "seq": { "type": "number" }, "ts": { "type": "number" }, "action": { "type": "string" }, "actor_kind": { "type": "string" }, "actor_id": { "type": "string" }, "resource": { "type": "string" }, "outcome": { "type": "string" }, "details": { "type": "string" }, "account_id": { "type": "string" }, "request_id": { "type": "string" }, "prev_hash": { "type": "string" }, "hash": { "type": "string" } }, "required": [ "seq", "ts", "action", "actor_kind", "actor_id", "resource", "outcome", "details", "account_id", "request_id", "prev_hash", "hash" ], "additionalProperties": false } }, "names": { "type": "object", "properties": { "owners": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] }, "identities": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] }, "keys": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "revoked": { "type": "boolean" } }, "required": [ "name", "revoked" ], "additionalProperties": false } }, { "type": "null" } ] }, "grants": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "revoked": { "type": "boolean" } }, "required": [ "name", "revoked" ], "additionalProperties": false } }, { "type": "null" } ] }, "contacts": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] } }, "required": [ "owners", "identities", "keys", "grants", "contacts" ], "additionalProperties": false } }, "required": [ "rows", "names" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/audit' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: badges > The portal shell's counts in one read: unread over every thread (counted up to a cap), unread per thread of the newest page, contact requests waiting, requests parked for a person ## The portal shell’s counts in one read: unread over every thread (counted up to a cap), unread per thread of the newest page, contact requests waiting, requests parked for a person `GET /v1/identities/{slug}/badges` · operation `getIdentityBadges` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | The portal shell’s counts in one read: unread over every thread (counted up to a cap), unread per thread of the newest page, contact requests waiting, requests parked for a person | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "unread": { "type": "object", "properties": { "count": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "capped": { "type": "boolean" } }, "required": [ "count", "capped" ], "additionalProperties": false }, "threads": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "contact_fpr": { "type": "string" }, "unread": { "type": "number" } }, "required": [ "id", "contact_fpr", "unread" ], "additionalProperties": false } }, "pending_in": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "parked": { "type": "number" }, "names": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] } }, "required": [ "unread", "threads", "pending_in", "parked", "names" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/badges' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: card > The identity's own contact card, as a peer would fetch it ## The identity’s own contact card, as a peer would fetch it `GET /v1/identities/{slug}/card` · operation `getIdentityCard` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | --------------------------------------------------------- | | 200 | The identity’s own contact card, as a peer would fetch it | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "card": { "type": "string" }, "root_fingerprint": { "type": "string" }, "kid": { "type": "string" }, "endpoint": { "type": "string" } }, "required": [ "card", "root_fingerprint", "kid", "endpoint" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/card' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: certificate > Whether the identity is certified, and its root, chain, validity and pending CSR (SPEC §2) ## Whether the identity is certified, and its root, chain, validity and pending CSR (SPEC §2) `GET /v1/identities/{slug}/certificate` · operation `getCertificate` Requires the `batondeck:identities:read` permission (action `cert:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------ | | 200 | Whether the identity is certified, and its root, chain, validity and pending CSR (SPEC §2) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "certified": { "type": "boolean" }, "root_fingerprint": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "chain": { "type": "array", "items": { "type": "string" } }, "kid": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "endpoint": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "not_before": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "not_after": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "renewal_due": { "type": "boolean" }, "expired": { "type": "boolean" }, "pending_csr": { "anyOf": [ { "type": "object", "properties": { "endpoint": { "type": "string" }, "purpose": { "type": "string" }, "created_at": { "type": "number" }, "key_fingerprint": { "type": "string" } }, "required": [ "endpoint", "purpose", "created_at", "key_fingerprint" ], "additionalProperties": false }, { "type": "null" } ] }, "superseded_kids": { "type": "array", "items": { "type": "string" } }, "accept_new_hosts": { "type": "string" }, "backup_verified_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "certified", "root_fingerprint", "chain", "kid", "endpoint", "not_before", "not_after", "renewal_due", "expired", "pending_csr", "superseded_kids", "accept_new_hosts", "backup_verified_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/certificate' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: changes > What has happened since a cursor, waiting up to 25 seconds for it to ## What has happened since a cursor, waiting up to 25 seconds for it to `GET /v1/identities/{slug}/changes` · operation `watchChanges` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ------- | ----- | ------- | -------- | ---------------------------------------------------------------- | | `slug` | path | string | yes | | | `since` | query | integer | yes | cursor from a previous answer; 0 starts from now with no backlog | | `wait` | query | integer | yes | seconds to wait for something to happen, from 1 to 25 | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------- | | 200 | What has happened since a cursor, waiting up to 25 seconds for it to | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "cursor": { "type": "number" }, "threads": { "type": "array", "items": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "contact_requests": { "type": "number" }, "contact_requests_new": { "type": "number" }, "pending_requests": { "type": "number" }, "pending_requests_new": { "type": "number" }, "calls": { "type": "array", "items": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "needs_attention": { "type": "array", "items": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "calls_truncated": { "type": "boolean" }, "timed_out": { "type": "boolean" } }, "required": [ "cursor", "threads", "contact_requests", "contact_requests_new", "pending_requests", "pending_requests_new", "calls", "needs_attention", "calls_truncated", "timed_out" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/changes' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: contacts > Contacts with their tier and switchboard; Ask the holder of a contact card to be a contact of this identity (SPEC §5.2, the manual flow); Set what a contact may do: a preset, or the switches one by one; Remove a contact: the pin goes on both sides and they are told (SPEC §5.3); Approve a contact req ## Contacts with their tier and switchboard `GET /v1/identities/{slug}/contacts` · operation `listContacts` Requires the `batondeck:contacts:read` permission (action `contact:read`). **Parameters** | Name | In | Type | Required | Notes | | -------- | ----- | ----------- | --------------- | ---------------- | | `slug` | path | string | yes | | | `status` | query | “active” \\ | “pending_in” \\ | “pending_out” \\ | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Contacts with their tier and switchboard | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "contacts": { "type": "array", "items": { "type": "object", "properties": { "fingerprint": { "type": "string" }, "display_name": { "type": "string" }, "status": { "type": "string", "enum": [ "active", "pending_in", "pending_out", "blocked" ] }, "preset": { "type": "string" }, "permissions": { "type": "array", "items": { "type": "string" } }, "trust_flag": { "type": "string" }, "petname": { "type": "string" }, "last_seen_at": { "type": "number" }, "created_at": { "type": "number" }, "endpoint": { "type": "string" }, "leaf": { "type": "string" }, "root_cert": { "type": "string" }, "address_claim": { "anyOf": [ { "type": "object", "properties": { "root": { "type": "string" }, "name": { "type": "string" } }, "required": [ "root", "name" ], "additionalProperties": false }, { "type": "null" } ] }, "acceptance_unheard_since": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "fingerprint", "display_name", "status", "preset", "permissions", "trust_flag", "petname", "last_seen_at", "created_at", "endpoint", "address_claim", "acceptance_unheard_since" ], "additionalProperties": false } } }, "required": [ "contacts" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/contacts' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Ask the holder of a contact card to be a contact of this identity (SPEC §5.2, the manual flow) `POST /v1/identities/{slug}/contacts` · operation `requestContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------ | ------ | -------- | ------------------------ | | `card` | string | yes | ≥ 1 chars, ≤ 16384 chars | | `note` | string | | ≤ 1024 chars | Request schema ```json { "type": "object", "properties": { "card": { "type": "string", "minLength": 1, "maxLength": 16384 }, "note": { "type": "string", "maxLength": 1024 } }, "required": [ "card" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Ask the holder of a contact card to be a contact of this identity (SPEC §5.2, the manual flow) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 201 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "pending", "active" ] }, "contact": { "type": "object", "properties": { "fingerprint": { "type": "string" }, "endpoint": { "type": "string" }, "display_name": { "type": "string" } }, "required": [ "fingerprint", "endpoint", "display_name" ], "additionalProperties": false } }, "required": [ "status", "contact" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Set what a contact may do: a preset, or the switches one by one `PATCH /v1/identities/{slug}/contacts/{fingerprint}` · operation `updateContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------------- | --------------- | -------------- | ---------- | | `preset` | “basic” \\ | “colleague” \\ | “close” \\ | | `permissions` | array of string | | | Request schema ```json { "type": "object", "properties": { "preset": { "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] }, "permissions": { "maxItems": 64, "type": "array", "items": { "type": "string", "maxLength": 96 } } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------- | | 200 | Set what a contact may do: a preset, or the switches one by one | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "contact": { "type": "object", "properties": { "fingerprint": { "type": "string" }, "display_name": { "type": "string" }, "status": { "type": "string", "enum": [ "active", "pending_in", "pending_out", "blocked" ] }, "preset": { "type": "string" }, "permissions": { "type": "array", "items": { "type": "string" } }, "trust_flag": { "type": "string" }, "petname": { "type": "string" }, "last_seen_at": { "type": "number" }, "created_at": { "type": "number" }, "endpoint": { "type": "string" }, "leaf": { "type": "string" }, "root_cert": { "type": "string" }, "address_claim": { "anyOf": [ { "type": "object", "properties": { "root": { "type": "string" }, "name": { "type": "string" } }, "required": [ "root", "name" ], "additionalProperties": false }, { "type": "null" } ] }, "acceptance_unheard_since": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "fingerprint", "display_name", "status", "preset", "permissions", "trust_flag", "petname", "last_seen_at", "created_at", "endpoint", "address_claim", "acceptance_unheard_since" ], "additionalProperties": false } }, "required": [ "contact" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Remove a contact: the pin goes on both sides and they are told (SPEC §5.3) `DELETE /v1/identities/{slug}/contacts/{fingerprint}` · operation `removeContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Remove a contact: the pin goes on both sides and they are told (SPEC §5.3) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "removed" ] }, "fingerprint": { "type": "string" }, "notified": { "type": "boolean" } }, "required": [ "status", "fingerprint", "notified" ], "additionalProperties": false } ``` ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Approve a contact request and set the preset it starts on `POST /v1/identities/{slug}/contacts/{fingerprint}/approve` · operation `approveContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ---------- | -------------- | ---------- | | `preset` | “basic” \\ | “colleague” \\ | “close” \\ | Request schema ```json { "type": "object", "properties": { "preset": { "default": "basic", "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] } }, "required": [ "preset" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Approve a contact request and set the preset it starts on | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "approved" ] }, "fingerprint": { "type": "string" }, "preset": { "type": "string" }, "notified": { "type": "boolean" } }, "required": [ "status", "fingerprint", "preset", "notified" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/approve' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Reject a contact request: they are blocked, so they cannot knock again, and they are told (SPEC §5.1) `POST /v1/identities/{slug}/contacts/{fingerprint}/reject` · operation `rejectContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Reject a contact request: they are blocked, so they cannot knock again, and they are told (SPEC §5.1) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "rejected" ] }, "fingerprint": { "type": "string" }, "notified": { "type": "boolean" } }, "required": [ "status", "fingerprint", "notified" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/reject' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Tell an active contact again that we accepted them (SPEC §5.1), when the approval did not reach them `POST /v1/identities/{slug}/contacts/{fingerprint}/tell-accepted` · operation `notifyAcceptance` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Tell an active contact again that we accepted them (SPEC §5.1), when the approval did not reach them | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "fingerprint": { "type": "string" }, "notified": { "type": "boolean" }, "refusal": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "fingerprint", "notified", "refusal" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/tell-accepted' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## The tools a contact offers us, asked of them over the sealed handshake `GET /v1/identities/{slug}/contacts/{fingerprint}/tools` · operation `listContactTools` Requires the `batondeck:contacts:read` permission (action `contact:read`). **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | The tools a contact offers us, asked of them over the sealed handshake | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "tools": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "input_schema": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "required": [ "name", "description", "input_schema" ], "additionalProperties": false } } }, "required": [ "tools" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/tools' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Call one of a contact’s tools as this identity (SPEC §5.4) `POST /v1/identities/{slug}/contacts/{fingerprint}/call` · operation `callContactTool` Requires the `batondeck:messages:send` permission (action `message:send`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------- | ------ | -------- | --------- | | `tool` | string | yes | ≥ 1 chars | | `arguments` | object | | | Request schema ```json { "type": "object", "properties": { "tool": { "type": "string", "minLength": 1 }, "arguments": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "required": [ "tool" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Call one of a contact’s tools as this identity (SPEC §5.4) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "ok": { "type": "boolean" }, "result": {} }, "required": [ "ok", "result" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/call' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Block a contact; the block is silent to them (SPEC §5.5) `POST /v1/identities/{slug}/contacts/{fingerprint}/block` · operation `blockContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------- | | 200 | Block a contact; the block is silent to them (SPEC §5.5) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "blocked" ] }, "fingerprint": { "type": "string" } }, "required": [ "status", "fingerprint" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/block' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Undo a block: a former contact returns to active as they were; a declined request is forgotten (SPEC §5) `POST /v1/identities/{slug}/contacts/{fingerprint}/unblock` · operation `unblockContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------- | | 200 | Undo a block: a former contact returns to active as they were; a declined request is forgotten (SPEC §5) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "active", "forgotten" ] }, "fingerprint": { "type": "string" } }, "required": [ "status", "fingerprint" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/unblock' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## The owner’s own private name for a contact `PATCH /v1/identities/{slug}/contacts/{fingerprint}/petname` · operation `setPetname` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | --------- | ------ | -------- | ---------- | | `petname` | string | yes | ≤ 64 chars | Request schema ```json { "type": "object", "properties": { "petname": { "type": "string", "maxLength": 64 } }, "required": [ "petname" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | The owner’s own private name for a contact | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "null" } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/petname' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Re-fetch ONE contact’s signed card now, and say what was found (HDTP §14.3) `POST /v1/identities/{slug}/contacts/{fingerprint}/refresh` · operation `refreshContact` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Request body** (`application/json`) Request schema ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Re-fetch ONE contact’s signed card now, and say what was found (HDTP §14.3) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "outcome": { "type": "string", "enum": [ "unchanged", "updated", "renewed", "unreachable", "refused" ] }, "why": { "type": "string" } }, "required": [ "outcome" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/refresh' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Whether this contact may instruct, or only send messages (SPEC §6.2) `PATCH /v1/identities/{slug}/contacts/{fingerprint}/trust` · operation `setTrust` Requires the `batondeck:contacts:trust` permission (action `contact:trust`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `fingerprint` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------- | ------------------ | -------------- | ----- | | `trust` | “messages_only” \\ | “may_instruct” | yes | Request schema ```json { "type": "object", "properties": { "trust": { "type": "string", "enum": [ "messages_only", "may_instruct" ] } }, "required": [ "trust" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------- | | 200 | Whether this contact may instruct, or only send messages (SPEC §6.2) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "null" } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/identities/:slug/contacts/:fingerprint/trust' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: csr > Mint a key and a certificate signing request for the wallet to sign (SPEC §9) ## Mint a key and a certificate signing request for the wallet to sign (SPEC §9) `POST /v1/identities/{slug}/csr` · operation `issueCsr` Requires the `batondeck:identities:manage` permission (action `cert:csr`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ---------- | ----------- | ---------- | ---------------------- | | `purpose` | “signup” \\ | “renew” \\ | “move” | | `endpoint` | string | | ≥ 1 chars, ≤ 512 chars | Request schema ```json { "type": "object", "properties": { "purpose": { "type": "string", "enum": [ "signup", "renew", "move" ] }, "endpoint": { "type": "string", "minLength": 1, "maxLength": 512 } }, "required": [ "purpose" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------------------- | | 201 | Mint a key and a certificate signing request for the wallet to sign (SPEC §9) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "csr": { "type": "string" }, "endpoint": { "type": "string" }, "purpose": { "type": "string" }, "suggested_not_after": { "type": "string" }, "previous_not_before": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "key_fingerprint": { "type": "string" } }, "required": [ "csr", "endpoint", "purpose", "suggested_not_after", "previous_not_before", "key_fingerprint" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/csr' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: digest > A per-contact summary of the last day, for an agent catching up ## A per-contact summary of the last day, for an agent catching up `GET /v1/identities/{slug}/digest` · operation `getDigest` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ------- | ----- | ------- | -------- | ----- | | `slug` | path | string | yes | | | `since` | query | integer | yes | | **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------- | | 200 | A per-contact summary of the last day, for an agent catching up | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/digest' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: export > This identity's contacts, threads, messages and files as one unencrypted zip (design §4), streamed and never stored; What this identity's export would say of itself, without the file: each import ceiling it passes, and every message it leaves out, by id and why ## This identity’s contacts, threads, messages and files as one unencrypted zip (design §4), streamed and never stored `GET /v1/identities/{slug}/export` · operation `exportIdentity` Requires the `batondeck:workspace:admin` permission (action `identity:export`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ----- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `slug` | path | string | yes | | | `acknowledge` | query | string | | Must be “unencrypted”: This file is not encrypted. Anyone who gets it can read your contact list and all your conversations and files. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported. | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------- | | 200 | This identity’s contacts, threads, messages and files as one unencrypted zip (design §4), streamed and never stored | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/export' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## What this identity’s export would say of itself, without the file: each import ceiling it passes, and every message it leaves out, by id and why `GET /v1/identities/{slug}/export/report` · operation `getExportReport` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | 200 | What this identity’s export would say of itself, without the file: each import ceiling it passes, and every message it leaves out, by id and why | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "warnings": { "type": "array", "items": { "type": "string" } }, "left_out": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "reason": { "type": "string" } }, "required": [ "id", "reason" ], "additionalProperties": false } }, "bytes": { "type": "number" } }, "required": [ "warnings", "left_out", "bytes" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/export/report' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: grants > Which owners may act as this identity. Several is a shared inbox (SPEC §3.3); Let another owner act as this identity, which is how a shared inbox is made; Take back an owner's access to this identity ## Which owners may act as this identity. Several is a shared inbox (SPEC §3.3) `GET /v1/identities/{slug}/grants` · operation `listIdentityGrants` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------- | | 200 | Which owners may act as this identity. Several is a shared inbox (SPEC §3.3) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "grants": { "type": "array", "items": { "type": "object", "properties": { "owner_id": { "type": "string" }, "role": { "type": "string" }, "created_at": { "type": "number" } }, "required": [ "owner_id", "role", "created_at" ], "additionalProperties": false } } }, "required": [ "grants" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/grants' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Let another owner act as this identity, which is how a shared inbox is made `POST /v1/identities/{slug}/grants` · operation `grantIdentity` Requires the `batondeck:identities:manage` permission (action `identity:grant`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ---------- | ------ | -------- | --------- | | `owner_id` | string | yes | ≥ 1 chars | Request schema ```json { "type": "object", "properties": { "owner_id": { "type": "string", "minLength": 1 } }, "required": [ "owner_id" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------- | | 200 | Let another owner act as this identity, which is how a shared inbox is made | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "owner_id": { "type": "string" }, "role": { "type": "string" }, "created_at": { "type": "number" } }, "required": [ "owner_id", "role", "created_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/grants' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Take back an owner’s access to this identity `DELETE /v1/identities/{slug}/grants/{ownerId}` · operation `revokeIdentityGrant` Requires the `batondeck:identities:manage` permission (action `identity:revoke`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Allowed while the workspace is paused, suspended or on deletion hold: it only takes access away. **Parameters** | Name | In | Type | Required | Notes | | --------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `ownerId` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Take back an owner’s access to this identity | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "revoked": { "type": "boolean", "const": true } }, "required": [ "revoked" ], "additionalProperties": false } ``` ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug/grants/:ownerId' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: import > Review an export zip sent as base64url in JSON (up to 6,000,000 bytes): validated whole and its contacts shown, nothing written; the import names the digest this answers; Review an export zip sent as the raw body (application/zip, up to the host's import ceiling): the same review as the JSON door; A ## Review an export zip sent as base64url in JSON (up to 6,000,000 bytes): validated whole and its contacts shown, nothing written; the import names the digest this answers `POST /v1/identities/{slug}/import/review` · operation `reviewImportArchive` Requires the `batondeck:identities:manage` permission (action `identity:import`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | --------- | ------ | -------- | --------- | | `archive` | string | yes | ≥ 1 chars | Request schema ```json { "type": "object", "properties": { "archive": { "type": "string", "minLength": 1 } }, "required": [ "archive" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Review an export zip sent as base64url in JSON (up to 6,000,000 bytes): validated whole and its contacts shown, nothing written; the import names the digest this answers | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "digest": { "type": "string" }, "root_fingerprint": { "type": "string" }, "contacts": { "type": "array", "items": { "type": "object", "properties": { "root": { "type": "string" }, "name": { "type": "string" }, "display_name": { "type": "string" }, "endpoint": { "type": "string" }, "status": { "type": "string" }, "leaf": { "type": "boolean" }, "action": { "type": "string", "enum": [ "add", "pin", "keep", "skip" ] }, "conflicts": { "type": "array", "items": { "type": "object", "properties": { "field": { "type": "string" }, "held": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "row": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "field", "held", "row" ], "additionalProperties": false } } }, "required": [ "root", "name", "display_name", "endpoint", "status", "leaf", "action", "conflicts" ], "additionalProperties": false } }, "counts": { "type": "object", "properties": { "contacts": { "type": "number" }, "threads": { "type": "number" }, "messages": { "type": "number" }, "media": { "type": "number" }, "media_bytes": { "type": "number" } }, "required": [ "contacts", "threads", "messages", "media", "media_bytes" ], "additionalProperties": false }, "expires_at": { "type": "number" } }, "required": [ "digest", "root_fingerprint", "contacts", "counts", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/import/review' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Review an export zip sent as the raw body (application/zip, up to the host’s import ceiling): the same review as the JSON door `PUT /v1/identities/{slug}/import/review` · operation `uploadImportReview` Requires the `batondeck:identities:manage` permission (action `identity:import`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/zip`): The file itself, with its Content-Length, up to 99614720 bytes. **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------ | | 200 | Review an export zip sent as the raw body (application/zip, up to the host’s import ceiling): the same review as the JSON door | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "digest": { "type": "string" }, "root_fingerprint": { "type": "string" }, "contacts": { "type": "array", "items": { "type": "object", "properties": { "root": { "type": "string" }, "name": { "type": "string" }, "display_name": { "type": "string" }, "endpoint": { "type": "string" }, "status": { "type": "string" }, "leaf": { "type": "boolean" }, "action": { "type": "string", "enum": [ "add", "pin", "keep", "skip" ] }, "conflicts": { "type": "array", "items": { "type": "object", "properties": { "field": { "type": "string" }, "held": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "row": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "field", "held", "row" ], "additionalProperties": false } } }, "required": [ "root", "name", "display_name", "endpoint", "status", "leaf", "action", "conflicts" ], "additionalProperties": false } }, "counts": { "type": "object", "properties": { "contacts": { "type": "number" }, "threads": { "type": "number" }, "messages": { "type": "number" }, "media": { "type": "number" }, "media_bytes": { "type": "number" } }, "required": [ "contacts", "threads", "messages", "media", "media_bytes" ], "additionalProperties": false }, "expires_at": { "type": "number" } }, "required": [ "digest", "root_fingerprint", "contacts", "counts", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X PUT 'https://api.batondeck.com/v1/identities/:slug/import/review' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/zip' \ --data-binary @file ``` ## An open review of an import: the contacts shown for that file, until it closes `GET /v1/identities/{slug}/import/review/{digest}` · operation `getImportReview` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | -------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `digest` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------ | | 200 | An open review of an import: the contacts shown for that file, until it closes | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "digest": { "type": "string" }, "root_fingerprint": { "type": "string" }, "contacts": { "type": "array", "items": { "type": "object", "properties": { "root": { "type": "string" }, "name": { "type": "string" }, "display_name": { "type": "string" }, "endpoint": { "type": "string" }, "status": { "type": "string" }, "leaf": { "type": "boolean" }, "action": { "type": "string", "enum": [ "add", "pin", "keep", "skip" ] }, "conflicts": { "type": "array", "items": { "type": "object", "properties": { "field": { "type": "string" }, "held": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "row": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "field", "held", "row" ], "additionalProperties": false } } }, "required": [ "root", "name", "display_name", "endpoint", "status", "leaf", "action", "conflicts" ], "additionalProperties": false } }, "counts": { "type": "object", "properties": { "contacts": { "type": "number" }, "threads": { "type": "number" }, "messages": { "type": "number" }, "media": { "type": "number" }, "media_bytes": { "type": "number" } }, "required": [ "contacts", "threads", "messages", "media", "media_bytes" ], "additionalProperties": false }, "expires_at": { "type": "number" } }, "required": [ "digest", "root_fingerprint", "contacts", "counts", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/import/review/:digest' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Close an open review of an import before its time: the review and the uploaded file it holds go, and nothing else changes `DELETE /v1/identities/{slug}/import/review/{digest}` · operation `cancelImportReview` Requires the `batondeck:identities:manage` permission (action `identity:import`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | -------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `digest` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug/import/review/:digest' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Take in a reviewed export zip, named by its review’s digest: the file the review holds is read again and written, refused unless it reads as reviewed; answers the renew request the wallet signs next `POST /v1/identities/{slug}/import` · operation `importArchive` Requires the `batondeck:identities:manage` permission (action `identity:import`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ------ | -------- | ----- | | `digest` | string | yes | | Request schema ```json { "type": "object", "properties": { "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, "required": [ "digest" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 200 | Take in a reviewed export zip, named by its review’s digest: the file the review holds is read again and written, refused unless it reads as reviewed; answers the renew request the wallet signs next | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "root_fingerprint": { "type": "string" }, "contacts": { "type": "object", "properties": { "written": { "type": "array", "items": { "type": "string" } }, "kept": { "type": "array", "items": { "type": "string" } }, "leafless": { "type": "array", "items": { "type": "string" } }, "conflicts": { "type": "number" } }, "required": [ "written", "kept", "leafless", "conflicts" ], "additionalProperties": false }, "threads": { "type": "number" }, "messages": { "type": "number" }, "media": { "type": "number" }, "renewal": { "type": "object", "properties": { "csr": { "type": "string" }, "endpoint": { "type": "string" }, "purpose": { "type": "string" }, "suggested_not_after": { "type": "string" }, "previous_not_before": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "key_fingerprint": { "type": "string" } }, "required": [ "csr", "endpoint", "purpose", "suggested_not_after", "previous_not_before", "key_fingerprint" ], "additionalProperties": false } }, "required": [ "root_fingerprint", "contacts", "threads", "messages", "media", "renewal" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/import' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: inbox > Thread count, unread total and newest activity over every thread, counted by the object ## Thread count, unread total and newest activity over every thread, counted by the object `GET /v1/identities/{slug}/inbox` · operation `getInboxTotals` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------------------- | | 200 | Thread count, unread total and newest activity over every thread, counted by the object | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "threads": { "type": "number" }, "unread": { "type": "number" }, "last_at": { "type": "number" } }, "required": [ "threads", "unread", "last_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/inbox' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: integrations > The integrations this identity has connected, and whether each is healthy; Connect an MCP server: a catalogue entry in one click, or any server by its URL (SPEC §6.1-§6.4); Start the upstream OAuth ceremony and hand back the URL to send the owner to; What this integration offers, and what it could o ## The integrations this identity has connected, and whether each is healthy `GET /v1/identities/{slug}/integrations` · operation `listIntegrations` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------- | | 200 | The integrations this identity has connected, and whether each is healthy | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "integrations": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "slug": { "type": "string" }, "transport": { "type": "string" }, "endpoint": { "type": "string" }, "status": { "type": "string" }, "permission": { "type": "string" } }, "required": [ "id", "slug", "transport", "endpoint", "status", "permission" ], "additionalProperties": false } } }, "required": [ "integrations" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/integrations' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Connect an MCP server: a catalogue entry in one click, or any server by its URL (SPEC §6.1-§6.4) `POST /v1/identities/{slug}/integrations` · operation `createIntegration` Requires the `batondeck:identities:manage` permission (action `integration:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------- | -------------------- | -------- | --------------------- | | `catalogue` | string | | ≤ 64 chars | | `slug` | string | | ≥ 2 chars, ≤ 32 chars | | `endpoint` | string | | | | `transport` | “streamable-http” \\ | “sse” | | | `auth` | object \\ | null | | Request schema ```json { "type": "object", "properties": { "catalogue": { "type": "string", "maxLength": 64 }, "slug": { "type": "string", "minLength": 2, "maxLength": 32 }, "endpoint": { "type": "string", "format": "uri" }, "transport": { "type": "string", "enum": [ "streamable-http", "sse" ] }, "auth": { "anyOf": [ { "type": "object", "properties": { "header": { "type": "string", "minLength": 1, "maxLength": 64 }, "value": { "type": "string", "minLength": 1, "maxLength": 4096 } }, "required": [ "header", "value" ], "additionalProperties": false }, { "type": "null" } ] } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------ | | 201 | Connect an MCP server: a catalogue entry in one click, or any server by its URL (SPEC §6.1-§6.4) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "integration": { "type": "object", "properties": { "id": { "type": "string" }, "slug": { "type": "string" }, "transport": { "type": "string" }, "endpoint": { "type": "string" }, "status": { "type": "string" }, "permission": { "type": "string" } }, "required": [ "id", "slug", "transport", "endpoint", "status", "permission" ], "additionalProperties": false }, "tools": { "type": "number" }, "trouble": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "authorization_url": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "integration", "tools", "trouble", "authorization_url" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/integrations' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Start the upstream OAuth ceremony and hand back the URL to send the owner to `POST /v1/identities/{slug}/integrations/{name}/authorize` · operation `authorizeIntegration` Requires the `batondeck:identities:manage` permission (action `integration:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `name` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------- | ------ | -------- | ----------- | | `scope` | string | | ≤ 512 chars | Request schema ```json { "type": "object", "properties": { "scope": { "type": "string", "maxLength": 512 } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------- | | 200 | Start the upstream OAuth ceremony and hand back the URL to send the owner to | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "authorization_url": { "type": "string" } }, "required": [ "authorization_url" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/integrations/:name/authorize' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## What this integration offers, and what it could offer (SPEC §6.5) `GET /v1/identities/{slug}/integrations/{name}/exposure` · operation `getExposure` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `name` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------- | | 200 | What this integration offers, and what it could offer (SPEC §6.5) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "catalog": { "type": "array", "items": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "entries": { "type": "array", "items": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "version": { "type": "number" }, "stale": { "type": "array", "items": { "type": "string" } } }, "required": [ "catalog", "entries", "version", "stale" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/integrations/:name/exposure' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Replace which of an integration’s tools contacts may reach `POST /v1/identities/{slug}/integrations/{name}/exposure` · operation `setExposure` Requires the `batondeck:identities:manage` permission (action `integration:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `name` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | --------- | --------------- | -------- | ----- | | `entries` | array of object | yes | | Request schema ```json { "type": "object", "properties": { "entries": { "type": "array", "items": { "type": "object", "properties": { "tool": { "type": "string" }, "mode": { "type": "string", "enum": [ "passthrough", "mapped", "agent" ] } }, "required": [ "tool", "mode" ], "additionalProperties": {} } } }, "required": [ "entries" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------- | | 200 | Replace which of an integration’s tools contacts may reach | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "version": { "type": "number" } }, "required": [ "version" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/integrations/:name/exposure' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Disconnect an upstream and forget its credential `DELETE /v1/identities/{slug}/integrations/{name}` · operation `deleteIntegration` Requires the `batondeck:identities:manage` permission (action `integration:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `name` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Disconnect an upstream and forget its credential | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "null" } ``` ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug/integrations/:name' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: invites > Invites this identity has issued. Each carries its link where the token was kept, and only for a caller who may mint an invite (contact:write); Mint an invite. The link is in the answer, and listInvites answers it again, to a caller who may mint one, for as long as the row exists; Accept somebody el ## Invites this identity has issued. Each carries its link where the token was kept, and only for a caller who may mint an invite (contact:write) `GET /v1/identities/{slug}/invites` · operation `listInvites` Requires the `batondeck:contacts:read` permission (action `contact:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Invites this identity has issued. Each carries its link where the token was kept, and only for a caller who may mint an invite (contact:write) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "invites": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" }, "preset": { "type": "string" }, "uses": { "type": "number" }, "max_uses": { "type": "number" }, "auto_accept": { "type": "boolean" }, "expires_at": { "type": "number" }, "revoked_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "url": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "id", "label", "preset", "uses", "max_uses", "auto_accept", "expires_at", "revoked_at", "url" ], "additionalProperties": false } } }, "required": [ "invites" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/invites' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Mint an invite. The link is in the answer, and listInvites answers it again, to a caller who may mint one, for as long as the row exists `POST /v1/identities/{slug}/invites` · operation `createInvite` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------------- | ---------- | -------------- | ----------------------------------------------------------- | | `label` | string | yes | ≤ 128 chars, default “” | | `preset` | “basic” \\ | “colleague” \\ | “close” \\ | | `max_uses` | integer | yes | 1 for one-time, 0 for unlimited. min 0, max 1000, default 1 | | `auto_accept` | boolean | yes | default false | | `expires_in_days` | integer | yes | min 1, max 90, default 14 | Request schema ```json { "type": "object", "properties": { "label": { "default": "", "type": "string", "maxLength": 128 }, "preset": { "default": "basic", "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] }, "max_uses": { "description": "1 for one-time, 0 for unlimited", "default": 1, "type": "integer", "minimum": 0, "maximum": 1000 }, "auto_accept": { "default": false, "type": "boolean" }, "expires_in_days": { "default": 14, "type": "integer", "minimum": 1, "maximum": 90 } }, "required": [ "label", "preset", "max_uses", "auto_accept", "expires_in_days" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Mint an invite. The link is in the answer, and listInvites answers it again, to a caller who may mint one, for as long as the row exists | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | 201 response schema ```json { "type": "object", "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "expires_at": { "type": "number" } }, "required": [ "id", "url", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/invites' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Accept somebody else’s invite link: this identity becomes their contact `POST /v1/identities/{slug}/invites/redeem` · operation `redeemInvite` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ----- | ------ | -------- | ------------------------ | | `url` | string | yes | ≥ 12 chars, ≤ 2048 chars | Request schema ```json { "type": "object", "properties": { "url": { "type": "string", "minLength": 12, "maxLength": 2048 } }, "required": [ "url" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Accept somebody else’s invite link: this identity becomes their contact | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 201 response schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "accepted", "pending" ] }, "contact": { "type": "object", "properties": { "fingerprint": { "type": "string" }, "endpoint": { "type": "string" }, "display_name": { "type": "string" } }, "required": [ "fingerprint", "endpoint", "display_name" ], "additionalProperties": false } }, "required": [ "status", "contact" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/invites/redeem' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Revoke an invite; a revoked token is indistinguishable from one that never existed `DELETE /v1/identities/{slug}/invites/{id}` · operation `revokeInvite` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/identities/:slug/invites/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: leaf > Install the chain the wallet issued: the identity becomes, or renews as, 2.0 ## Install the chain the wallet issued: the identity becomes, or renews as, 2.0 `POST /v1/identities/{slug}/leaf` · operation `installLeaf` Requires the `batondeck:identities:manage` permission (action `cert:install`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------------- | --------------- | -------- | ----- | | `chain` | array of string | yes | | | `credential_id` | string \\ | null | | | `backup_verified` | boolean \\ | null | | Request schema ```json { "type": "object", "properties": { "chain": { "minItems": 2, "maxItems": 2, "type": "array", "items": { "type": "string", "minLength": 1 } }, "credential_id": { "anyOf": [ { "type": "string", "minLength": 1, "maxLength": 512 }, { "type": "null" } ] }, "backup_verified": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "chain" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------- | | 201 | Install the chain the wallet issued: the identity becomes, or renews as, 2.0 | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "root_fingerprint": { "type": "string" }, "kid": { "type": "string" }, "endpoint": { "type": "string" }, "not_before": { "type": "string" }, "not_after": { "type": "string" }, "purpose": { "type": "string" }, "first": { "type": "boolean" }, "superseded_kid": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "root_fingerprint", "kid", "endpoint", "not_before", "not_after", "purpose", "first", "superseded_kid" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/leaf' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: media > Send a file to a contact (SPEC §7.4) ## Send a file to a contact (SPEC §7.4) `POST /v1/identities/{slug}/media` · operation `sendMedia` Requires the `batondeck:messages:send` permission (action `message:send`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------------- | ------ | -------- | ----------------------------------------------- | | `fingerprint` | string | yes | ≥ 1 chars | | `data` | string | yes | ≥ 1 chars | | `filename` | string | yes | ≤ 256 chars, default “” | | `mime` | string | yes | ≤ 128 chars, default “application/octet-stream” | | `thread_id` | string | | ≤ 128 chars | | `msg_id` | string | | ≤ 128 chars | Request schema ```json { "type": "object", "properties": { "fingerprint": { "type": "string", "minLength": 1 }, "data": { "type": "string", "minLength": 1 }, "filename": { "default": "", "type": "string", "maxLength": 256 }, "mime": { "default": "application/octet-stream", "type": "string", "maxLength": 128 }, "thread_id": { "type": "string", "maxLength": 128 }, "msg_id": { "type": "string", "maxLength": 128 } }, "required": [ "fingerprint", "data", "filename", "mime" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Send a file to a contact (SPEC §7.4) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 201 response schema ```json { "type": "object", "properties": { "status": { "type": "string" }, "message_id": { "type": "string" }, "thread_id": { "type": "string" }, "hash": { "type": "string" } }, "required": [ "status", "message_id", "thread_id", "hash" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/media' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: messages > Send a message to a contact as this identity; Try again now to deliver an outbound text message that has not arrived (pending, or given up on) ## Send a message to a contact as this identity `POST /v1/identities/{slug}/messages` · operation `sendMessage` Requires the `batondeck:messages:send` permission (action `message:send`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------------- | ------ | -------- | ------------------------ | | `fingerprint` | string | yes | ≥ 1 chars | | `text` | string | yes | ≥ 1 chars, ≤ 16384 chars | | `thread_id` | string | | ≤ 128 chars | | `msg_id` | string | | ≤ 128 chars | Request schema ```json { "type": "object", "properties": { "fingerprint": { "type": "string", "minLength": 1 }, "text": { "type": "string", "minLength": 1, "maxLength": 16384 }, "thread_id": { "type": "string", "maxLength": 128 }, "msg_id": { "type": "string", "maxLength": 128 } }, "required": [ "fingerprint", "text" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 201 | Send a message to a contact as this identity | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 201 response schema ```json { "type": "object", "properties": { "status": { "type": "string" }, "message_id": { "type": "string" }, "thread_id": { "type": "string" } }, "required": [ "status", "message_id", "thread_id" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/messages' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Try again now to deliver an outbound text message that has not arrived (pending, or given up on) `POST /v1/identities/{slug}/messages/{id}/retry` · operation `retryMessage` Requires the `batondeck:messages:send` permission (action `message:send`). Refused while the workspace is suspended or on deletion hold. Accepts an `Idempotency-Key` header. A repeat within 24 hours returns the first answer; the same key with different arguments is refused with `idempotency_mismatch`. **Parameters** | Name | In | Type | Required | Notes | | ----------------- | ------ | ------ | -------- | ----------------------------------------------------------------- | | `slug` | path | string | yes | | | `id` | path | string | yes | | | `Idempotency-Key` | header | string | | Repeat this value to retry the call without repeating its effect. | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Try again now to deliver an outbound text message that has not arrived (pending, or given up on) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | | 422 | That Idempotency-Key was used with different arguments. | | 429 | This identity’s outbound budget (HDTP §12: 1 call a second per contact with a burst of 10, the identity’s aggregate, 20 an hour to strangers), or the peer’s own, refused the call; `retry_after` and Retry-After say when to try again. | 200 response schema ```json { "type": "object", "properties": { "status": { "type": "string" }, "message_id": { "type": "string" }, "thread_id": { "type": "string" }, "attempts": { "type": "number" }, "last_refusal": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "status", "message_id", "thread_id", "attempts", "last_refusal" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/messages/:id/retry' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: pending > Answer a request an integration parked for a person (SPEC §6.8); Agent-answered requests a caller is waiting on (SPEC §6.8) ## Answer a request an integration parked for a person (SPEC §6.8) `POST /v1/identities/{slug}/pending/{id}` · operation `answerPendingRequest` Requires the `batondeck:requests:answer` permission (action `requests:answer`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `id` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ---- | -------- | ----------------- | | `answer` | | yes | the reply payload | Request schema ```json { "type": "object", "properties": { "answer": { "description": "the reply payload" } }, "required": [ "answer" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------- | | 200 | Answer a request an integration parked for a person (SPEC §6.8) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "relayed": { "type": "boolean" } }, "required": [ "relayed" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/pending/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Agent-answered requests a caller is waiting on (SPEC §6.8) `GET /v1/identities/{slug}/pending` · operation `listPendingRequests` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------- | | 200 | Agent-answered requests a caller is waiting on (SPEC §6.8) | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "requests": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "contact_fpr": { "type": "string" }, "capability": { "type": "string" }, "args": { "type": "string" }, "trust_flag": { "type": "string" }, "created_at": { "type": "number" }, "expires_at": { "type": "number" } }, "required": [ "id", "contact_fpr", "capability", "args", "trust_flag", "created_at", "expires_at" ], "additionalProperties": false } } }, "required": [ "requests" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/pending' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: presets > This identity's own preset bundles, and which have been edited; Change what a preset grants, for this identity ## This identity’s own preset bundles, and which have been edited `GET /v1/identities/{slug}/presets` · operation `listIdentityPresets` Requires the `batondeck:contacts:read` permission (action `contact:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------- | | 200 | This identity’s own preset bundles, and which have been edited | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "presets": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "permissions": { "type": "array", "items": { "type": "string" } }, "edited": { "type": "boolean" } }, "required": [ "name", "permissions", "edited" ], "additionalProperties": false } } }, "required": [ "presets" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/presets' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Change what a preset grants, for this identity `PATCH /v1/identities/{slug}/presets/{name}` · operation `setIdentityPreset` Requires the `batondeck:contacts:manage` permission (action `contact:write`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `name` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------------- | --------------- | -------- | ----- | | `permissions` | array of string | yes | | Request schema ```json { "type": "object", "properties": { "permissions": { "maxItems": 64, "type": "array", "items": { "type": "string" } } }, "required": [ "permissions" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Change what a preset grants, for this identity | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "name": { "type": "string" }, "permissions": { "type": "array", "items": { "type": "string" } } }, "required": [ "name", "permissions" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/identities/:slug/presets/:name' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: settings > This identity's own settings: accept_new_hosts, the owner's status and moved_away_at; What happens when a pinned contact turns up at a new address (accept_new_hosts), the owner's status, which decides what contacts read with get_status, or, alone, moved_away: no renewal reminders ## This identity’s own settings: accept_new_hosts, the owner’s status and moved_away_at `GET /v1/identities/{slug}/settings` · operation `getIdentitySettings` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------ | | 200 | This identity’s own settings: accept_new_hosts, the owner’s status and moved_away_at | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "accept_new_hosts": { "type": "string" }, "status": { "type": "string", "enum": [ "auto", "available", "not_available", "disabled" ] }, "status_updated_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "moved_away_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "accept_new_hosts", "status", "status_updated_at", "moved_away_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/settings' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## What happens when a pinned contact turns up at a new address (accept_new_hosts), the owner’s status, which decides what contacts read with get_status, or, alone, moved_away: no renewal reminders `PATCH /v1/identities/{slug}/settings` · operation `updateIdentitySettings` Requires the `batondeck:identities:manage` permission (action `identity:update`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ------------------ | --------- | -------------- | ------------------ | | `accept_new_hosts` | “auto” \\ | “ask” | | | `status` | “auto” \\ | “available” \\ | “not_available” \\ | | `moved_away` | boolean | | | Request schema ```json { "type": "object", "properties": { "accept_new_hosts": { "type": "string", "enum": [ "auto", "ask" ] }, "status": { "type": "string", "enum": [ "auto", "available", "not_available", "disabled" ] }, "moved_away": { "type": "boolean" } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | What happens when a pinned contact turns up at a new address (accept_new_hosts), the owner’s status, which decides what contacts read with get_status, or, alone, moved_away: no renewal reminders | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "accept_new_hosts": { "type": "string" }, "status": { "type": "string", "enum": [ "auto", "available", "not_available", "disabled" ] }, "status_updated_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "moved_away_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "accept_new_hosts", "status", "status_updated_at", "moved_away_at" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/identities/:slug/settings' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: storage > Bytes this identity holds, and the ceiling it is held against ## Bytes this identity holds, and the ceiling it is held against `GET /v1/identities/{slug}/storage` · operation `getStorage` Requires the `batondeck:identities:read` permission (action `identity:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------- | | 200 | Bytes this identity holds, and the ceiling it is held against | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "used": { "type": "number" }, "quota": { "type": "number" }, "blobs": { "type": "number" } }, "required": [ "used", "quota", "blobs" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/storage' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Identity: threads > Message threads, newest first, with unread counts; One thread by its id, whatever page it is on, with its unread count; The newest messages in one thread, oldest first within the window; The owner has read this thread, through the message `through` names or, with no body, all of it (SPEC §7.6) ## Message threads, newest first, with unread counts `GET /v1/identities/{slug}/threads` · operation `listThreads` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | -------- | ----- | ------- | -------- | ----- | | `slug` | path | string | yes | | | `limit` | query | integer | yes | | | `cursor` | query | string | | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Message threads, newest first, with unread counts | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "threads": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "contact_fpr": { "type": "string" }, "topic": { "type": "string" }, "last_at": { "type": "number" }, "unread": { "type": "number" } }, "required": [ "id", "contact_fpr", "topic", "last_at", "unread" ], "additionalProperties": false } }, "cursor": { "type": "string" } }, "required": [ "threads" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/threads' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## One thread by its id, whatever page it is on, with its unread count `GET /v1/identities/{slug}/threads/{threadId}` · operation `getThread` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ---------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `threadId` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------- | | 200 | One thread by its id, whatever page it is on, with its unread count | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "id": { "type": "string" }, "contact_fpr": { "type": "string" }, "topic": { "type": "string" }, "last_at": { "type": "number" }, "unread": { "type": "number" } }, "required": [ "id", "contact_fpr", "topic", "last_at", "unread" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/threads/:threadId' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## The newest messages in one thread, oldest first within the window `GET /v1/identities/{slug}/threads/{threadId}/messages` · operation `listMessages` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ---------- | ----- | ------- | -------- | ----- | | `slug` | path | string | yes | | | `threadId` | path | string | yes | | | `limit` | query | integer | yes | | **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------- | | 200 | The newest messages in one thread, oldest first within the window | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "thread_id": { "type": "string" }, "trust": { "type": "string" }, "messages": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "thread_id": { "type": "string" }, "contact_fpr": { "type": "string" }, "direction": { "type": "string", "enum": [ "in", "out" ] }, "sender": { "type": "string", "enum": [ "agent", "human" ] }, "body": { "type": "string" }, "kind": { "type": "string" }, "reply_to": { "type": "string" }, "status": { "type": "string" }, "created_at": { "type": "number" }, "expires_at": { "type": "number" }, "attempts": { "type": "number" }, "last_refusal": { "anyOf": [ { "type": "string" }, { "type": "null" } ] } }, "required": [ "id", "thread_id", "contact_fpr", "direction", "sender", "body", "kind", "reply_to", "status", "created_at", "expires_at", "attempts", "last_refusal" ], "additionalProperties": false } } }, "required": [ "thread_id", "trust", "messages" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/threads/:threadId/messages' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## The owner has read this thread, through the message `through` names or, with no body, all of it (SPEC §7.6) `POST /v1/identities/{slug}/threads/{threadId}/read` · operation `markThreadRead` Requires the `batondeck:messages:read` permission (action `message:read`). **Parameters** | Name | In | Type | Required | Notes | | ---------- | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `threadId` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | --------- | ------ | -------- | --------- | | `through` | string | | ≥ 1 chars | Request schema ```json { "type": "object", "properties": { "through": { "type": "string", "minLength": 1 } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------------- | | 200 | The owner has read this thread, through the message `through` names or, with no body, all of it (SPEC §7.6) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "thread_id": { "type": "string" }, "unread": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } }, "required": [ "thread_id", "unread" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/threads/:threadId/read' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Identity: wallet request > Mint a CSR and open a single-use request for the wallet page to sign (onboarding/wallet design §1a); What the wallet did with a request: still open, the chain it issued, or why it did not ## Mint a CSR and open a single-use request for the wallet page to sign (onboarding/wallet design §1a) `POST /v1/identities/{slug}/wallet-request` · operation `openWalletRequest` Requires the `batondeck:identities:manage` permission (action `cert:csr`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | ---------- | ----------- | ---------- | ---------------------- | | `purpose` | “signup” \\ | “renew” \\ | “move” | | `endpoint` | string | | ≥ 1 chars, ≤ 512 chars | Request schema ```json { "type": "object", "properties": { "purpose": { "type": "string", "enum": [ "signup", "renew", "move" ] }, "endpoint": { "type": "string", "minLength": 1, "maxLength": 512 } }, "required": [ "purpose" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------- | | 201 | Mint a CSR and open a single-use request for the wallet page to sign (onboarding/wallet design §1a) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "code": { "type": "string" }, "url": { "type": "string" }, "endpoint": { "type": "string" }, "purpose": { "type": "string" }, "expires_at": { "type": "number" } }, "required": [ "code", "url", "endpoint", "purpose", "expires_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/identities/:slug/wallet-request' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## What the wallet did with a request: still open, the chain it issued, or why it did not `GET /v1/identities/{slug}/wallet-request/{code}` · operation `readWalletRequest` Requires the `batondeck:identities:read` permission (action `cert:read`). **Parameters** | Name | In | Type | Required | Notes | | ------ | ---- | ------ | -------- | ----- | | `slug` | path | string | yes | | | `code` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------- | | 200 | What the wallet did with a request: still open, the chain it issued, or why it did not | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "state": { "type": "string", "enum": [ "open", "answered", "cancelled", "failed", "expired" ] }, "chain": { "anyOf": [ { "minItems": 2, "maxItems": 2, "type": "array", "items": { "type": "string" } }, { "type": "null" } ] }, "root_fingerprint": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "credential_id": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "backup_verified": { "type": "boolean" }, "why": { "type": "string" } }, "required": [ "state", "chain", "root_fingerprint", "credential_id", "backup_verified", "why" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/identities/:slug/wallet-request/:code' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Integrations > The MCP servers that connect in one click: each id, name, category, one line, endpoint and icon ## The MCP servers that connect in one click: each id, name, category, one line, endpoint and icon `GET /v1/integrations/catalogue` · operation `listIntegrationCatalogue` Requires the `batondeck:identities:read` permission (action `identity:list`). **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------- | | 200 | The MCP servers that connect in one click: each id, name, category, one line, endpoint and icon | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "servers": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "category": { "type": "string" }, "summary": { "type": "string" }, "endpoint": { "type": "string" }, "icon": { "type": "string" } }, "required": [ "id", "name", "category", "summary", "endpoint", "icon" ], "additionalProperties": false } } }, "required": [ "servers" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/integrations/catalogue' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Presets > The permission presets a contact or an invite can be put on ## The permission presets a contact or an invite can be put on `GET /v1/presets` · operation `listPresets` Requires the `batondeck:identities:read` permission (action `identity:list`). **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------- | | 200 | The permission presets a contact or an invite can be put on | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "presets": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "permissions": { "type": "array", "items": { "type": "string" } } }, "required": [ "name", "permissions" ], "additionalProperties": false } }, "permissions": { "type": "array", "items": { "type": "string" } } }, "required": [ "presets", "permissions" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/presets' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Sessions > The caller's own signed-in sessions, across every workspace, newest first; the one this request came in on is marked; Sign one of the caller's other sessions out; it is refused on its next request; Sign out every session of the caller's but the one this request came in on ## The caller’s own signed-in sessions, across every workspace, newest first; the one this request came in on is marked `GET /v1/sessions` · operation `listSessions` Requires the `batondeck:workspace:read` permission (action `session:list`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------- | | 200 | The caller’s own signed-in sessions, across every workspace, newest first; the one this request came in on is marked | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "sessions": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "device": { "type": "string" }, "country": { "type": "string" }, "created_at": { "type": "number" }, "last_seen_at": { "type": "number" }, "expires_at": { "type": "number" }, "current": { "type": "boolean" } }, "required": [ "id", "device", "country", "created_at", "last_seen_at", "expires_at", "current" ], "additionalProperties": false } } }, "required": [ "sessions" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/sessions' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Sign one of the caller’s other sessions out; it is refused on its next request `DELETE /v1/sessions/{id}` · operation `revokeSession` Requires the `batondeck:workspace:read` permission (action `session:revoke`). Allowed while the workspace is paused, suspended or on deletion hold: it only takes access away. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/sessions/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Sign out every session of the caller’s but the one this request came in on `POST /v1/sessions/sign-out-others` · operation `revokeOtherSessions` Requires the `batondeck:workspace:read` permission (action `session:revoke`). Allowed while the workspace is paused, suspended or on deletion hold: it only takes access away. **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------- | | 200 | Sign out every session of the caller’s but the one this request came in on | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "revoked": { "type": "number" } }, "required": [ "revoked" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/sessions/sign-out-others' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Statuses > The choices an identity's status may be set to, in the order to offer them, and how recent a use keeps Auto available ## The choices an identity’s status may be set to, in the order to offer them, and how recent a use keeps Auto available `GET /v1/statuses` · operation `listStatusChoices` Requires the `batondeck:identities:read` permission (action `identity:list`). **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------------------------- | | 200 | The choices an identity’s status may be set to, in the order to offer them, and how recent a use keeps Auto available | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "statuses": { "type": "array", "items": { "type": "string", "enum": [ "auto", "available", "not_available", "disabled" ] } }, "auto_window_ms": { "type": "number" } }, "required": [ "statuses", "auto_window_ms" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/statuses' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace > The workspace this session is signed in to; Rename the workspace ## The workspace this session is signed in to `GET /v1/workspace` · operation `getWorkspace` Requires the `batondeck:workspace:read` permission (action `workspace:read`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | The workspace this session is signed in to | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "slug": { "type": "string" }, "status": { "type": "string" }, "plan": { "type": "string" }, "jurisdiction": { "type": "string" }, "hold_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "paused_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "session_max_age_hours": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" } }, "required": [ "id", "name", "slug", "status", "plan", "jurisdiction", "hold_until", "paused_at", "session_max_age_hours", "created_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Rename the workspace `PATCH /v1/workspace` · operation `updateWorkspace` Requires the `batondeck:workspace:admin` permission (action `workspace:update`). Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------------------- | ---------- | -------- | --------------------- | | `name` | string | | ≥ 1 chars, ≤ 64 chars | | `session_max_age_hours` | integer \\ | null | | Request schema ```json { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 64 }, "session_max_age_hours": { "anyOf": [ { "type": "integer", "minimum": 1, "maximum": 168 }, { "type": "null" } ] } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Rename the workspace | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "slug": { "type": "string" }, "status": { "type": "string" }, "plan": { "type": "string" }, "jurisdiction": { "type": "string" }, "hold_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "paused_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "session_max_age_hours": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" } }, "required": [ "id", "name", "slug", "status", "plan", "jurisdiction", "hold_until", "paused_at", "session_max_age_hours", "created_at" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/workspace' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Workspace: audit > The newest rows of the workspace's audit chain, oldest first within the window ## The newest rows of the workspace’s audit chain, oldest first within the window `GET /v1/workspace/audit` · operation `listWorkspaceAudit` Requires the `batondeck:audit:read` permission (action `audit:read`). **Parameters** | Name | In | Type | Required | Notes | | ------- | ----- | ------- | -------- | ----- | | `limit` | query | integer | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------ | | 200 | The newest rows of the workspace’s audit chain, oldest first within the window | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "rows": { "type": "array", "items": { "type": "object", "properties": { "seq": { "type": "number" }, "ts": { "type": "number" }, "actor": { "type": "string" }, "kind": { "type": "string" }, "details": { "type": "string" }, "prev_hash": { "type": "string" }, "hash": { "type": "string" } }, "required": [ "seq", "ts", "actor", "kind", "details", "prev_hash", "hash" ], "additionalProperties": false } }, "events": { "type": "array", "items": { "type": "object", "properties": { "seq": { "type": "number" }, "ts": { "type": "number" }, "actor": { "type": "string" }, "kind": { "type": "string" }, "details": { "type": "string" } }, "required": [ "seq", "ts", "actor", "kind", "details" ], "additionalProperties": false } }, "names": { "type": "object", "properties": { "owners": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] }, "identities": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "string" } }, { "type": "null" } ] }, "keys": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "revoked": { "type": "boolean" } }, "required": [ "name", "revoked" ], "additionalProperties": false } }, { "type": "null" } ] }, "grants": { "anyOf": [ { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": { "type": "object", "properties": { "name": { "type": "string" }, "revoked": { "type": "boolean" } }, "required": [ "name", "revoked" ], "additionalProperties": false } }, { "type": "null" } ] } }, "required": [ "owners", "identities", "keys", "grants" ], "additionalProperties": false } }, "required": [ "rows", "events", "names" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/audit' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: billing > Whether a plan can be bought here, which ones, and whether a subscription exists to manage; A Stripe Checkout link for a paid plan (review P-14); A link to Stripe's customer portal, where the subscription is changed or cancelled ## Whether a plan can be bought here, which ones, and whether a subscription exists to manage `GET /v1/workspace/billing` · operation `getBilling` Requires the `batondeck:billing:read` permission (action `plan:read`). **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------ | | 200 | Whether a plan can be bought here, which ones, and whether a subscription exists to manage | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "enabled": { "type": "boolean" }, "purchasable": { "type": "array", "items": { "type": "string" } }, "managed": { "type": "boolean" } }, "required": [ "enabled", "purchasable", "managed" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/billing' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## A Stripe Checkout link for a paid plan (review P-14) `POST /v1/workspace/billing/checkout` · operation `openCheckout` Requires the `batondeck:billing:manage` permission (action `plan:change`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ------ | -------- | --------- | ------------ | | `plan` | “pro” \\ | “team” \\ | “enterprise” | Request schema ```json { "type": "object", "properties": { "plan": { "type": "string", "enum": [ "pro", "team", "enterprise" ] } }, "required": [ "plan" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------- | | 200 | A Stripe Checkout link for a paid plan (review P-14) | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "url": { "type": "string" } }, "required": [ "url" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/billing/checkout' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## A link to Stripe’s customer portal, where the subscription is changed or cancelled `POST /v1/workspace/billing/portal` · operation `openBillingPortal` Requires the `batondeck:billing:manage` permission (action `plan:change`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) Request schema ```json { "type": "object", "properties": {}, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------- | | 200 | A link to Stripe’s customer portal, where the subscription is changed or cancelled | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "url": { "type": "string" } }, "required": [ "url" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/billing/portal' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Workspace: deletion > Schedule this workspace for deletion, after a seven-day hold; Change your mind inside the seven days ## Schedule this workspace for deletion, after a seven-day hold `POST /v1/workspace/deletion` · operation `requestWorkspaceDeletion` Requires the `batondeck:workspace:admin` permission (action `workspace:delete`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ------ | -------- | ----------- | | `reason` | string | | ≤ 500 chars | Request schema ```json { "type": "object", "properties": { "reason": { "type": "string", "maxLength": 500 } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------ | | 201 | Schedule this workspace for deletion, after a seven-day hold | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "hold_until": { "type": "number" } }, "required": [ "hold_until" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/deletion' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Change your mind inside the seven days `DELETE /v1/workspace/deletion` · operation `cancelWorkspaceDeletion` Requires the `batondeck:workspace:admin` permission (action `workspace:cancel-deletion`). Refused while the workspace is suspended or on deletion hold. **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Change your mind inside the seven days | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "null" } ``` ```sh curl -X DELETE 'https://api.batondeck.com/v1/workspace/deletion' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: domains > Every hostname the workspace holds, the platform name included; Add a custom domain, on a plan that carries `custom_domain`; answers the DNS records to create; One custom domain: its status, the DNS records to create and what is still needed, read live; Remove a custom domain no identity lives at; i ## Every hostname the workspace holds, the platform name included `GET /v1/workspace/domains` · operation `listDomains` Requires the `batondeck:workspace:read` permission (action `workspace:read`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------- | | 200 | Every hostname the workspace holds, the platform name included | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "domains": { "type": "array", "items": { "type": "object", "properties": { "hostname": { "type": "string" }, "kind": { "type": "string" }, "status": { "type": "string" }, "custom_domain": { "type": "boolean" }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "hostname", "kind", "status", "custom_domain", "created_at", "updated_at" ], "additionalProperties": false } } }, "required": [ "domains" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/domains' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Add a custom domain, on a plan that carries `custom_domain`; answers the DNS records to create `POST /v1/workspace/domains` · operation `addCustomDomain` Requires the `batondeck:workspace:admin` permission (action `domain:manage`). Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ---------- | ------ | -------- | ---------------------- | | `hostname` | string | yes | ≥ 3 chars, ≤ 253 chars | Request schema ```json { "type": "object", "properties": { "hostname": { "type": "string", "minLength": 3, "maxLength": 253 } }, "required": [ "hostname" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------- | | 201 | Add a custom domain, on a plan that carries `custom_domain`; answers the DNS records to create | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "hostname": { "type": "string" }, "status": { "type": "string" }, "reason": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "records": { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string", "enum": [ "CNAME", "TXT" ] }, "name": { "type": "string" }, "value": { "type": "string" } }, "required": [ "type", "name", "value" ], "additionalProperties": false } }, "identities": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "hostname", "status", "reason", "records", "identities", "created_at", "updated_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/domains' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## One custom domain: its status, the DNS records to create and what is still needed, read live `GET /v1/workspace/domains/{hostname}` · operation `getCustomDomain` Requires the `batondeck:workspace:read` permission (action `workspace:read`). **Parameters** | Name | In | Type | Required | Notes | | ---------- | ---- | ------ | -------- | ----- | | `hostname` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------- | | 200 | One custom domain: its status, the DNS records to create and what is still needed, read live | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "hostname": { "type": "string" }, "status": { "type": "string" }, "reason": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "records": { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string", "enum": [ "CNAME", "TXT" ] }, "name": { "type": "string" }, "value": { "type": "string" } }, "required": [ "type", "name", "value" ], "additionalProperties": false } }, "identities": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "hostname", "status", "reason", "records", "identities", "created_at", "updated_at" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/domains/:hostname' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Remove a custom domain no identity lives at; its certificate stops being served `DELETE /v1/workspace/domains/{hostname}` · operation `removeCustomDomain` Requires the `batondeck:workspace:admin` permission (action `domain:remove`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ---------- | ---- | ------ | -------- | ----- | | `hostname` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/workspace/domains/:hostname' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Check the domain now instead of waiting for the five-minute poll, and record the result `POST /v1/workspace/domains/{hostname}/check` · operation `checkCustomDomain` Requires the `batondeck:workspace:admin` permission (action `domain:manage`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ---------- | ---- | ------ | -------- | ----- | | `hostname` | path | string | yes | | **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------------------- | | 200 | Check the domain now instead of waiting for the five-minute poll, and record the result | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "hostname": { "type": "string" }, "status": { "type": "string" }, "reason": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "records": { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string", "enum": [ "CNAME", "TXT" ] }, "name": { "type": "string" }, "value": { "type": "string" } }, "required": [ "type", "name", "value" ], "additionalProperties": false } }, "identities": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "hostname", "status", "reason", "records", "identities", "created_at", "updated_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/domains/:hostname/check' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: export > Everything this workspace holds, as one unencrypted zip streamed and never stored: the control-plane document, every identity's export zip, and an index ## Everything this workspace holds, as one unencrypted zip streamed and never stored: the control-plane document, every identity’s export zip, and an index `GET /v1/workspace/export` · operation `exportWorkspace` Requires the `batondeck:workspace:admin` permission (action `export:read`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. **Parameters** | Name | In | Type | Required | Notes | | ------------- | ----- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `acknowledge` | query | string | | Must be “unencrypted”: This file is not encrypted. Anyone who gets it can read your contact list and all your conversations and files. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported. | **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Everything this workspace holds, as one unencrypted zip streamed and never stored: the control-plane document, every identity’s export zip, and an index | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/export' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: keys > Agent keys: the caller's own, or every owner's for an admin. Never the keys themselves; Mint an agent key for the caller. The key is in this response and nowhere else, ever; Revoke an agent key: the caller's own, or any for an admin. The row stays, so the audit rows it wrote still name it ## Agent keys: the caller’s own, or every owner’s for an admin. Never the keys themselves `GET /v1/workspace/keys` · operation `listApiKeys` Requires the `batondeck:workspace:read` permission (action `apikey:read`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------------------- | | 200 | Agent keys: the caller’s own, or every owner’s for an admin. Never the keys themselves | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "keys": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "prefix": { "type": "string" }, "owner_id": { "type": "string" }, "scopes": { "type": "array", "items": { "type": "string" } }, "identity_ids": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "number" }, "last_used_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "revoked_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "expires_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "made_by": { "type": "string" } }, "required": [ "id", "name", "prefix", "owner_id", "scopes", "identity_ids", "created_at", "last_used_at", "revoked_at", "expires_at", "made_by" ], "additionalProperties": false } }, "limit": { "type": "number" }, "live": { "type": "number" } }, "required": [ "keys", "limit", "live" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/keys' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Mint an agent key for the caller. The key is in this response and nowhere else, ever `POST /v1/workspace/keys` · operation `mintApiKey` Requires the `batondeck:workspace:read` permission (action `apikey:mint`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ----------------- | --------------- | -------- | ---------------------- | | `name` | string | yes | ≥ 1 chars, ≤ 100 chars | | `scopes` | array of string | | | | `everything` | boolean | | | | `identity_ids` | array of string | | | | `expires_in_days` | integer | | min 1, max 365 | Request schema ```json { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 100 }, "scopes": { "minItems": 1, "type": "array", "items": { "type": "string" } }, "everything": { "type": "boolean" }, "identity_ids": { "type": "array", "items": { "type": "string" } }, "expires_in_days": { "type": "integer", "minimum": 1, "maximum": 365 } }, "required": [ "name" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------ | | 201 | Mint an agent key for the caller. The key is in this response and nowhere else, ever | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "key": { "type": "string" }, "api_key": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "prefix": { "type": "string" }, "owner_id": { "type": "string" }, "scopes": { "type": "array", "items": { "type": "string" } }, "identity_ids": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "number" }, "last_used_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "revoked_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "expires_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "made_by": { "type": "string" } }, "required": [ "id", "name", "prefix", "owner_id", "scopes", "identity_ids", "created_at", "last_used_at", "revoked_at", "expires_at", "made_by" ], "additionalProperties": false } }, "required": [ "key", "api_key" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/keys' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Revoke an agent key: the caller’s own, or any for an admin. The row stays, so the audit rows it wrote still name it `DELETE /v1/workspace/keys/{id}` · operation `revokeApiKey` Requires the `batondeck:workspace:read` permission (action `apikey:revoke`). Allowed while the workspace is paused, suspended or on deletion hold: it only takes access away. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/workspace/keys/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: members > The owners in the workspace and which of them administer it ## The owners in the workspace and which of them administer it `GET /v1/workspace/members` · operation `listMembers` Requires the `batondeck:members:read` permission (action `member:list`). **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------- | | 200 | The owners in the workspace and which of them administer it | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "members": { "type": "array", "items": { "type": "object", "properties": { "owner_id": { "type": "string" }, "name": { "type": "string" }, "workspace_admin": { "type": "boolean" }, "role": { "type": "string" }, "created_at": { "type": "number" } }, "required": [ "owner_id", "name", "workspace_admin", "role", "created_at" ], "additionalProperties": false } } }, "required": [ "members" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/members' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: pause > Pause the workspace: restriction of processing, which you set and you lift; Lift your own pause ## Pause the workspace: restriction of processing, which you set and you lift `POST /v1/workspace/pause` · operation `pauseWorkspace` Requires the `batondeck:workspace:admin` permission (action `workspace:pause`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ------ | -------- | ----------- | | `reason` | string | | ≤ 500 chars | Request schema ```json { "type": "object", "properties": { "reason": { "type": "string", "maxLength": 500 } }, "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------------------- | | 201 | Pause the workspace: restriction of processing, which you set and you lift | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "paused_at": { "type": "number" } }, "required": [ "paused_at" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/pause' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Lift your own pause `DELETE /v1/workspace/pause` · operation `resumeWorkspace` Requires the `batondeck:workspace:admin` permission (action `workspace:resume`). Refused while the workspace is suspended or on deletion hold. **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | Lift your own pause | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "paused": { "type": "boolean" }, "suspended": { "type": "boolean" } }, "required": [ "paused", "suspended" ], "additionalProperties": false } ``` ```sh curl -X DELETE 'https://api.batondeck.com/v1/workspace/pause' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: plan > The plan in force and every entitlement it resolves to ## The plan in force and every entitlement it resolves to `GET /v1/workspace/plan` · operation `getWorkspacePlan` Requires the `batondeck:billing:read` permission (action `plan:read`). **Responses** | Status | Meaning | | ------ | ------------------------------------------------------ | | 200 | The plan in force and every entitlement it resolves to | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "plan": { "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, "required": [ "id", "name" ], "additionalProperties": false }, "entitlements": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "anyOf": [ { "type": "number" }, { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ] }, "source": { "type": "string" }, "expires_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] } }, "required": [ "key", "value", "source", "expires_at" ], "additionalProperties": false } } }, "required": [ "plan", "entitlements" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/plan' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: residency > Where this workspace's data is held, and whether the Enterprise residency guarantee is in effect ## Where this workspace’s data is held, and whether the Enterprise residency guarantee is in effect `GET /v1/workspace/residency` · operation `getResidency` Requires the `batondeck:workspace:read` permission (action `residency:read`). **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------ | | 200 | Where this workspace’s data is held, and whether the Enterprise residency guarantee is in effect | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "jurisdiction": { "anyOf": [ { "type": "string", "enum": [ "eu", "us" ] }, { "type": "null" } ] }, "entitled": { "type": "boolean" }, "in_effect": { "type": "boolean" }, "reason": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "covered": { "type": "array", "items": { "type": "string" } }, "not_covered": { "type": "array", "items": { "type": "string" } } }, "required": [ "jurisdiction", "entitled", "in_effect", "reason", "covered", "not_covered" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/residency' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: settings > The five knobs, each fixed by the platform, with the reason ## The five knobs, each fixed by the platform, with the reason `GET /v1/workspace/settings` · operation `listSettings` Requires the `batondeck:workspace:read` permission (action `workspace:read`). **Responses** | Status | Meaning | | ------ | ----------------------------------------------------------- | | 200 | The five knobs, each fixed by the platform, with the reason | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "settings": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" }, "locked": { "type": "boolean" }, "reason": { "type": "string" }, "restart_scoped": { "type": "boolean" } }, "required": [ "key", "value", "locked", "reason", "restart_scoped" ], "additionalProperties": false } } }, "required": [ "settings" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/settings' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: sso > Whether this workspace requires single sign-on, its connections and its domains; Require single sign-on for this workspace, or stop requiring it; A short-lived link into the identity provider's Admin Portal, to set up a connection or verify a domain ## Whether this workspace requires single sign-on, its connections and its domains `GET /v1/workspace/sso` · operation `getSso` Requires the `batondeck:workspace:read` permission (action `sso:read`). **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------- | | 200 | Whether this workspace requires single sign-on, its connections and its domains | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "entitled": { "type": "boolean" }, "required": { "type": "boolean" }, "provider_configured": { "type": "boolean" }, "connections": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "type": "string" }, "state": { "type": "string" } }, "required": [ "id", "type", "state" ], "additionalProperties": false } }, "domains": { "type": "array", "items": { "type": "object", "properties": { "domain": { "type": "string" }, "state": { "type": "string" } }, "required": [ "domain", "state" ], "additionalProperties": false } }, "signed_in_with_sso": { "type": "boolean" } }, "required": [ "entitled", "required", "provider_configured", "connections", "domains", "signed_in_with_sso" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/sso' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Require single sign-on for this workspace, or stop requiring it `PATCH /v1/workspace/sso` · operation `setSsoRequired` Requires the `batondeck:workspace:admin` permission (action `sso:manage`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | ---------- | ------- | -------- | ----- | | `required` | boolean | yes | | Request schema ```json { "type": "object", "properties": { "required": { "type": "boolean" } }, "required": [ "required" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------- | | 200 | Require single sign-on for this workspace, or stop requiring it | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "entitled": { "type": "boolean" }, "required": { "type": "boolean" }, "provider_configured": { "type": "boolean" }, "connections": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "type": "string" }, "state": { "type": "string" } }, "required": [ "id", "type", "state" ], "additionalProperties": false } }, "domains": { "type": "array", "items": { "type": "object", "properties": { "domain": { "type": "string" }, "state": { "type": "string" } }, "required": [ "domain", "state" ], "additionalProperties": false } }, "signed_in_with_sso": { "type": "boolean" } }, "required": [ "entitled", "required", "provider_configured", "connections", "domains", "signed_in_with_sso" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/workspace/sso' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## A short-lived link into the identity provider’s Admin Portal, to set up a connection or verify a domain `POST /v1/workspace/sso/portal-link` · operation `createSsoPortalLink` Requires the `batondeck:workspace:admin` permission (action `sso:manage`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | -------- | --------------------- | ----- | | `intent` | “sso” \\ | “domain_verification” | yes | Request schema ```json { "type": "object", "properties": { "intent": { "type": "string", "enum": [ "sso", "domain_verification" ] } }, "required": [ "intent" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------- | | 200 | A short-lived link into the identity provider’s Admin Portal, to set up a connection or verify a domain | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "url": { "type": "string" } }, "required": [ "url" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/sso/portal-link' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` # Workspace: usage > Rolled-up usage, newest day first, one row per day and metric ## Rolled-up usage, newest day first, one row per day and metric `GET /v1/workspace/usage` · operation `listUsage` Requires the `batondeck:billing:read` permission (action `plan:read`). **Parameters** | Name | In | Type | Required | Notes | | ------- | ----- | ------- | -------- | ----- | | `limit` | query | integer | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------- | | 200 | Rolled-up usage, newest day first, one row per day and metric | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "usage": { "type": "array", "items": { "type": "object", "properties": { "day": { "type": "string" }, "metric": { "type": "string" }, "value": { "type": "number" } }, "required": [ "day", "metric", "value" ], "additionalProperties": false } } }, "required": [ "usage" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/usage' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # Workspace: webhooks > The workspace's webhook endpoints. Never their signing secrets; Register an endpoint. The signing secret is in this answer and nowhere else; A new signing secret; the old one keeps working for 24 hours; Pause an endpoint, or resume one — resuming clears its failure run; Remove an endpoint. Its deliv ## The workspace’s webhook endpoints. Never their signing secrets `GET /v1/workspace/webhooks` · operation `listWebhooks` Requires the `batondeck:workspace:admin` permission (action `webhook:read`). **Responses** | Status | Meaning | | ------ | -------------------------------------------------------------- | | 200 | The workspace’s webhook endpoints. Never their signing secrets | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "endpoints": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "events": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "consecutive_failures": { "type": "number" }, "rotating_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "id", "url", "events", "status", "consecutive_failures", "rotating_until", "created_at", "updated_at" ], "additionalProperties": false } }, "events": { "type": "array", "items": { "type": "string" } }, "enabled": { "type": "boolean" } }, "required": [ "endpoints", "events", "enabled" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/webhooks' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Register an endpoint. The signing secret is in this answer and nowhere else `POST /v1/workspace/webhooks` · operation `createWebhook` Requires the `batondeck:workspace:admin` permission (action `webhook:create`). Requires a step-up: MFA enrolled and re-authenticated within 15 minutes. **Not reachable with an API key** — a key has no session and so can never step up. Refused while the workspace is suspended or on deletion hold. **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | --------------- | -------- | ----------- | | `url` | string | yes | ≥ 1 chars | | `events` | array of string | yes | default \[] | Request schema ```json { "type": "object", "properties": { "url": { "type": "string", "minLength": 1 }, "events": { "default": [], "type": "array", "items": { "type": "string" } } }, "required": [ "url", "events" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | --------------------------------------------------------------------------- | | 201 | Register an endpoint. The signing secret is in this answer and nowhere else | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 201 response schema ```json { "type": "object", "properties": { "endpoint": { "type": "object", "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "events": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "consecutive_failures": { "type": "number" }, "rotating_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "id", "url", "events", "status", "consecutive_failures", "rotating_until", "created_at", "updated_at" ], "additionalProperties": false }, "secret": { "type": "string" } }, "required": [ "endpoint", "secret" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/webhooks' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## A new signing secret; the old one keeps working for 24 hours `POST /v1/workspace/webhooks/{id}/secret` · operation `rotateWebhookSecret` Requires the `batondeck:workspace:admin` permission (action `webhook:manage`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------ | | 200 | A new signing secret; the old one keeps working for 24 hours | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "secret": { "type": "string" }, "previous_accepted_until": { "type": "number" } }, "required": [ "secret", "previous_accepted_until" ], "additionalProperties": false } ``` ```sh curl -X POST 'https://api.batondeck.com/v1/workspace/webhooks/:id/secret' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## Pause an endpoint, or resume one — resuming clears its failure run `PATCH /v1/workspace/webhooks/{id}` · operation `setWebhookStatus` Requires the `batondeck:workspace:admin` permission (action `webhook:manage`). Refused while the workspace is suspended or on deletion hold. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Request body** (`application/json`) | Field | Type | Required | Notes | | -------- | ----------- | -------- | ----- | | `status` | “active” \\ | “paused” | yes | Request schema ```json { "type": "object", "properties": { "status": { "type": "string", "enum": [ "active", "paused" ] } }, "required": [ "status" ], "additionalProperties": false } ``` **Responses** | Status | Meaning | | ------ | ------------------------------------------------------------------ | | 200 | Pause an endpoint, or resume one — resuming clears its failure run | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "endpoints": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "url": { "type": "string" }, "events": { "type": "array", "items": { "type": "string" } }, "status": { "type": "string" }, "consecutive_failures": { "type": "number" }, "rotating_until": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" }, "updated_at": { "type": "number" } }, "required": [ "id", "url", "events", "status", "consecutive_failures", "rotating_until", "created_at", "updated_at" ], "additionalProperties": false } } }, "required": [ "endpoints" ], "additionalProperties": false } ``` ```sh curl -X PATCH 'https://api.batondeck.com/v1/workspace/webhooks/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" \ -H 'content-type: application/json' \ -d @body.json ``` ## Remove an endpoint. Its delivery log stays, so what it did is still readable `DELETE /v1/workspace/webhooks/{id}` · operation `deleteWebhook` Requires the `batondeck:workspace:admin` permission (action `webhook:delete`). Allowed while the workspace is paused, suspended or on deletion hold: it only takes access away. **Parameters** | Name | In | Type | Required | Notes | | ---- | ---- | ------ | -------- | ----- | | `id` | path | string | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 204 | Done. No body. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | ```sh curl -X DELETE 'https://api.batondeck.com/v1/workspace/webhooks/:id' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` ## What was attempted, when, and what came back `GET /v1/workspace/webhooks/{id}/deliveries` · operation `listWebhookDeliveries` Requires the `batondeck:workspace:admin` permission (action `webhook:read`). **Parameters** | Name | In | Type | Required | Notes | | ------- | ----- | ------- | -------- | ----- | | `id` | path | string | yes | | | `limit` | query | integer | yes | | **Responses** | Status | Meaning | | ------ | -------------------------------------------------- | | 200 | What was attempted, when, and what came back | | 400 | The arguments did not validate. | | 401 | No portal session, and no live API key. | | 403 | The policy refused, or the request was cross-site. | | 404 | No such resource, or none this session may see. | 200 response schema ```json { "type": "object", "properties": { "deliveries": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "endpoint_id": { "type": "string" }, "event_id": { "type": "string" }, "event_type": { "type": "string" }, "attempt": { "type": "number" }, "status": { "type": "string" }, "http_status": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "latency_ms": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "response_excerpt": { "anyOf": [ { "type": "string" }, { "type": "null" } ] }, "next_attempt_at": { "anyOf": [ { "type": "number" }, { "type": "null" } ] }, "created_at": { "type": "number" } }, "required": [ "id", "endpoint_id", "event_id", "event_type", "attempt", "status", "http_status", "latency_ms", "response_excerpt", "next_attempt_at", "created_at" ], "additionalProperties": false } } }, "required": [ "deliveries" ], "additionalProperties": false } ``` ```sh curl -X GET 'https://api.batondeck.com/v1/workspace/webhooks/:id/deliveries' \ -H "Authorization: Bearer $BATONDECK_API_KEY" ``` # The owner MCP server > BatonDeck's owner MCP: 12 router tools over 95 actions, each one a /v1 operation. **Endpoint:** `https://mcp.batondeck.com/mcp` · **Protocol:** `2026-07-28` (stateless, no handshake), and the `initialize` handshake at `2025-11-25` or `2025-06-18` · **Authorization:** OAuth, discovered from `https://mcp.batondeck.com/.well-known/oauth-protected-resource`. ## What the server tells a model The server sends this text as its instructions on `initialize`: > The owner surface of a BatonDeck workspace: read and answer your inbox, manage the contacts who may reach you, issue invites, run your identities, their integrations and your workspace, and read the audit chain. Tools are routers named batondeck\_\\_\: `read` tools change nothing but your own read marker, `write` tools only add, `change` tools alter or remove. Each takes `action` and `arguments`, and lists every action’s arguments. Every call acts as ONE identity: the one named by `?identity=` on the server URL, else the first this connection may act as; `batondeck_identity_read` `accounts` lists them and marks the acting one. Every change is recorded on that identity’s audit chain, naming the owner behind this connection. ## Tools The listing has 12 tools. Each takes `action` and `arguments`; the actions it accepts, and each action’s arguments, are below. A client that connects with `?expanded=true` on the server URL is listed one tool per action instead (95 tools, named `__`), with the same arguments. | Tool | Title | Actions | Hints | | ----------------------------------------------------------- | --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------- | | [`batondeck_inbox_read`](#batondeck_inbox_read) | Read the inbox | 5 | `readOnlyHint: true` · `openWorldHint: false` | | [`batondeck_inbox_write`](#batondeck_inbox_write) | Send a message or a file | 2 | `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_inbox_change`](#batondeck_inbox_change) | Call, retry or answer | 3 | `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_contacts_read`](#batondeck_contacts_read) | List contacts and invites | 6 | `readOnlyHint: true` · `openWorldHint: true` | | [`batondeck_contacts_change`](#batondeck_contacts_change) | Manage contacts and invites | 15 | `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_contacts_write`](#batondeck_contacts_write) | Invite or ask | 2 | `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_identity_read`](#batondeck_identity_read) | Read the identity | 14 | `readOnlyHint: true` · `openWorldHint: false` | | [`batondeck_identity_change`](#batondeck_identity_change) | Change the identity | 10 | `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_identity_write`](#batondeck_identity_write) | Connect, share or open a wallet request | 5 | `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_workspace_read`](#batondeck_workspace_read) | Read the workspace | 15 | `readOnlyHint: true` · `openWorldHint: true` | | [`batondeck_workspace_change`](#batondeck_workspace_change) | Change the workspace | 12 | `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | | [`batondeck_workspace_write`](#batondeck_workspace_write) | Create in the workspace | 6 | `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | ### batondeck_inbox_read **Read the inbox.** Threads, messages, pending requests, waits and digests. Reading a thread moves your own read marker and nothing else. `readOnlyHint: true` · `openWorldHint: false` | Action | What it does | Arguments | Scope | /v1 operation | | --------- | ----------------------------------------------------------------------- | ------------------------ | ------------ | --------------------- | | `list` | Threads newest first, with unread counts | `limit`?, `cursor`? | `inbox:read` | `listThreads` | | `read` | The messages in one thread; reading advances your read cursor | `thread_id`, `limit`? | `inbox:read` | `listMessages` | | `pending` | Requests waiting for the owner to answer | — | `inbox:read` | `listPendingRequests` | | `wait` | Block until something changes, then report what moved since your cursor | `since`?, `timeout_sec`? | `inbox:read` | `watchChanges` | | `digest` | What happened in a window and what is still open, per contact | `since`? | `inbox:read` | `getDigest` | batondeck_inbox_read input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "list", "read", "pending", "wait", "digest" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "list", "description": "Threads newest first, with unread counts", "type": "object", "properties": { "limit": { "default": 50, "type": "integer", "minimum": 1, "maximum": 200 }, "cursor": { "type": "string" } }, "additionalProperties": false, "examples": [ { "limit": 20 } ] }, { "title": "read", "description": "The messages in one thread; reading advances your read cursor", "type": "object", "properties": { "thread_id": { "type": "string", "description": "the threadId this acts on" }, "limit": { "default": 50, "type": "integer", "minimum": 1, "maximum": 200 } }, "required": [ "thread_id" ], "additionalProperties": false, "examples": [ { "thread_id": "t-1", "limit": 50 } ] }, { "title": "pending", "description": "Requests waiting for the owner to answer", "type": "object", "additionalProperties": false }, { "title": "wait", "description": "Block until something changes, then report what moved since your cursor", "type": "object", "properties": { "since": { "description": "cursor from a previous answer; 0 starts from now with no backlog", "default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "timeout_sec": { "description": "seconds to wait for something to happen, from 1 to 25", "default": 25, "type": "integer", "minimum": 1, "maximum": 25 } }, "additionalProperties": false, "examples": [ { "since": 1757203200000, "timeout_sec": 25 } ] }, { "title": "digest", "description": "What happened in a window and what is still open, per contact", "type": "object", "properties": { "since": { "default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991 } }, "additionalProperties": false, "examples": [ {} ] } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_inbox_write **Send a message or a file.** Send a message or a file to a contact. `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ----------- | -------------------------------- | -------------------------------------------------------------------- | ------------- | ------------- | | `send` | Send a message to a contact | `fingerprint`, `text`, `thread_id`?, `msg_id`? | `inbox:write` | `sendMessage` | | `send_file` | Send a file to a contact, base64 | `fingerprint`, `data`, `filename`?, `mime`?, `thread_id`?, `msg_id`? | `inbox:write` | `sendMedia` | batondeck_inbox_write input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "send", "send_file" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "send", "description": "Send a message to a contact", "type": "object", "properties": { "fingerprint": { "type": "string", "minLength": 1 }, "text": { "type": "string", "minLength": 1, "maxLength": 16384 }, "thread_id": { "type": "string", "maxLength": 128 }, "msg_id": { "type": "string", "maxLength": 128 } }, "required": [ "fingerprint", "text" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "text": "on my way" } ] }, { "title": "send_file", "description": "Send a file to a contact, base64", "type": "object", "properties": { "fingerprint": { "type": "string", "minLength": 1 }, "data": { "type": "string", "minLength": 1 }, "filename": { "default": "", "type": "string", "maxLength": 256 }, "mime": { "default": "application/octet-stream", "type": "string", "maxLength": 128 }, "thread_id": { "type": "string", "maxLength": 128 }, "msg_id": { "type": "string", "maxLength": 128 } }, "required": [ "fingerprint", "data" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "data": "aGVsbG8=", "filename": "note.txt", "mime": "text/plain" } ] } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_inbox_change **Call, retry or answer.** Call a tool on a contact’s node, retry a message that did not arrive, or answer a request a contact is waiting on. `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | -------- | ---------------------------------------------------------------- | ----------------------------------- | ----------------- | ---------------------- | | `call` | Call a tool on a contact’s node; their switchboard still applies | `fingerprint`, `tool`, `arguments`? | `inbox:write` | `callContactTool` | | `retry` | Retry an undelivered message | `message_id` | `inbox:write` | `retryMessage` | | `answer` | Answer an agent-answered request | `request_id`, `answer` | `requests:answer` | `answerPendingRequest` | batondeck_inbox_change input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "call", "retry", "answer" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "call", "description": "Call a tool on a contact's node; their switchboard still applies", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" }, "tool": { "type": "string", "minLength": 1 }, "arguments": { "type": "object", "propertyNames": { "type": "string" }, "additionalProperties": {} } }, "required": [ "fingerprint", "tool" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "tool": "check_availability", "arguments": { "note": "coffee?" } } ] }, { "title": "retry", "description": "Retry an undelivered message", "type": "object", "properties": { "message_id": { "type": "string", "description": "the id this acts on" } }, "required": [ "message_id" ], "additionalProperties": false, "examples": [ { "message_id": "m-1" } ] }, { "title": "answer", "description": "Answer an agent-answered request", "type": "object", "properties": { "request_id": { "type": "string", "description": "the id this acts on" }, "answer": { "description": "the reply payload" } }, "required": [ "request_id", "answer" ], "additionalProperties": false, "examples": [ { "request_id": "r-1", "answer": { "ok": true } } ] } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_contacts_read **List contacts and invites.** Contacts with their status, preset and permissions, the tools a contact offers you, contacts waiting at a new address, and the invites this identity has issued. `readOnlyHint: true` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ---------------- | ----------------------------------------------------------------- | ------------- | --------------- | ---------------------- | | `list` | Contacts with their tier and permissions | `status`? | `contacts:read` | `listContacts` | | `invites` | Invites this identity has issued: uses, and links if you may mint | — | `contacts:read` | `listInvites` | | `tools` | The tools a contact offers you, asked of their node | `fingerprint` | `contacts:read` | `listContactTools` | | `addresses` | Contacts waiting at a new address for your decision | — | `contacts:read` | `listPendingAddresses` | | `preset_catalog` | The shipped presets and every permission a contact can be granted | — | `identity:read` | `listPresets` | | `presets` | This identity’s own preset bundles, and which have been edited | — | `contacts:read` | `listIdentityPresets` | batondeck_contacts_read input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "list", "invites", "tools", "addresses", "preset_catalog", "presets" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "list", "description": "Contacts with their tier and permissions", "type": "object", "properties": { "status": { "description": "only contacts in this state", "type": "string", "enum": [ "active", "pending_in", "pending_out", "blocked" ] } }, "additionalProperties": false, "examples": [ {} ] }, { "title": "invites", "description": "Invites this identity has issued: uses, and links if you may mint", "type": "object", "additionalProperties": false }, { "title": "tools", "description": "The tools a contact offers you, asked of their node", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "addresses", "description": "Contacts waiting at a new address for your decision", "type": "object", "additionalProperties": false }, { "title": "preset_catalog", "description": "The shipped presets and every permission a contact can be granted", "type": "object", "additionalProperties": false }, { "title": "presets", "description": "This identity's own preset bundles, and which have been edited", "type": "object", "additionalProperties": false } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_contacts_change **Manage contacts and invites.** Approve, reject, block, unblock, remove, rename or refresh a contact, change what it may do or whether it may instruct you, decide on its new address, re-send your acceptance, accept its invite, or revoke one of yours. `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ----------------- | ------------------------------------------------------------------------------ | ---------------------------------------- | ----------------- | ----------------------- | | `approve` | Approve a pending contact request, applying a preset | `fingerprint`, `preset`? | `contacts:manage` | `approveContact` | | `reject` | Refuse a pending contact request | `fingerprint` | `contacts:manage` | `rejectContact` | | `block` | Stop a contact reaching you. Silent: they are not told (HDTP §6.2) | `fingerprint` | `contacts:manage` | `blockContact` | | `unblock` | Undo a block: a former contact returns active, a declined request is forgotten | `fingerprint` | `contacts:manage` | `unblockContact` | | `remove` | Remove a contact; an active one is told | `fingerprint` | `contacts:manage` | `removeContact` | | `add` | Accept their invite link | `invite_url` | `contacts:manage` | `redeemInvite` | | `tell_accepted` | Re-send your acceptance | `fingerprint` | `contacts:manage` | `notifyAcceptance` | | `rename` | Set your own local name for a contact; empty clears it | `fingerprint`, `petname` | `contacts:manage` | `setPetname` | | `permissions` | Change what a contact may do | `fingerprint`, `preset`?, `permissions`? | `contacts:trust` | `updateContact` | | `trust` | Whether this contact’s text may instruct your agent | `fingerprint`, `trust` | `contacts:trust` | `setTrust` | | `refresh` | Re-fetch a contact’s card | `fingerprint` | `contacts:manage` | `refreshContact` | | `revoke_invite` | Revoke an invite; its link stops working | `invite_id` | `invites:manage` | `revokeInvite` | | `approve_address` | Re-pin a contact at the new address it is waiting at | `root` | `contacts:manage` | `approvePendingAddress` | | `reject_address` | Keep the pin where it is; the new address is a stranger | `root` | `contacts:manage` | `rejectPendingAddress` | | `set_preset` | Change what a preset grants, for this identity; existing contacts keep theirs | `name`, `permissions` | `contacts:trust` | `setIdentityPreset` | batondeck_contacts_change input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "approve", "reject", "block", "unblock", "remove", "add", "tell_accepted", "rename", "permissions", "trust", "refresh", "revoke_invite", "approve_address", "reject_address", "set_preset" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "approve", "description": "Approve a pending contact request, applying a preset", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" }, "preset": { "default": "basic", "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "preset": "colleague" } ] }, { "title": "reject", "description": "Refuse a pending contact request", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "block", "description": "Stop a contact reaching you. Silent: they are not told (HDTP §6.2)", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "unblock", "description": "Undo a block: a former contact returns active, a declined request is forgotten", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "remove", "description": "Remove a contact; an active one is told", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "add", "description": "Accept their invite link", "type": "object", "properties": { "invite_url": { "type": "string", "minLength": 12, "maxLength": 2048 } }, "required": [ "invite_url" ], "additionalProperties": false, "examples": [ { "invite_url": "https://alex-ws.batondeck.com/alex/i/abc123" } ] }, { "title": "tell_accepted", "description": "Re-send your acceptance", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "rename", "description": "Set your own local name for a contact; empty clears it", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" }, "petname": { "type": "string", "maxLength": 64 } }, "required": [ "fingerprint", "petname" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "petname": "Carol from Pune" } ] }, { "title": "permissions", "description": "Change what a contact may do", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" }, "preset": { "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] }, "permissions": { "maxItems": 64, "type": "array", "items": { "type": "string", "maxLength": 96 } } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "preset": "close" } ] }, { "title": "trust", "description": "Whether this contact's text may instruct your agent", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" }, "trust": { "type": "string", "enum": [ "messages_only", "may_instruct" ] } }, "required": [ "fingerprint", "trust" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…", "trust": "messages_only" } ] }, { "title": "refresh", "description": "Re-fetch a contact's card", "type": "object", "properties": { "fingerprint": { "type": "string", "description": "the fingerprint this acts on" } }, "required": [ "fingerprint" ], "additionalProperties": false, "examples": [ { "fingerprint": "sha256:…" } ] }, { "title": "revoke_invite", "description": "Revoke an invite; its link stops working", "type": "object", "properties": { "invite_id": { "type": "string", "description": "the id this acts on" } }, "required": [ "invite_id" ], "additionalProperties": false, "examples": [ { "invite_id": "inv-1" } ] }, { "title": "approve_address", "description": "Re-pin a contact at the new address it is waiting at", "type": "object", "properties": { "root": { "type": "string", "description": "the root this acts on" } }, "required": [ "root" ], "additionalProperties": false, "examples": [ { "root": "sha256:…" } ] }, { "title": "reject_address", "description": "Keep the pin where it is; the new address is a stranger", "type": "object", "properties": { "root": { "type": "string", "description": "the root this acts on" } }, "required": [ "root" ], "additionalProperties": false, "examples": [ { "root": "sha256:…" } ] }, { "title": "set_preset", "description": "Change what a preset grants, for this identity; existing contacts keep theirs", "type": "object", "properties": { "name": { "type": "string", "description": "the name this acts on" }, "permissions": { "maxItems": 64, "type": "array", "items": { "type": "string" } } }, "required": [ "name", "permissions" ], "additionalProperties": false, "examples": [ { "name": "colleague", "permissions": [ "message.text", "status.view" ] } ] } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_contacts_write **Invite or ask.** Mint an invite link, or ask the holder of a contact card to be your contact. `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | --------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------- | ---------------- | | `invite` | Mint an invite link, optionally auto-accepting | `label`?, `preset`?, `max_uses`?, `auto_accept`?, `expires_in_days`? | `invites:manage` | `createInvite` | | `request` | Ask the holder of a contact card to be your contact; they approve it | `card`, `note`? | `contacts:manage` | `requestContact` | batondeck_contacts_write input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "invite", "request" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "invite", "description": "Mint an invite link, optionally auto-accepting", "type": "object", "properties": { "label": { "default": "", "type": "string", "maxLength": 128 }, "preset": { "default": "basic", "type": "string", "enum": [ "basic", "colleague", "close", "muted" ] }, "max_uses": { "description": "1 for one-time, 0 for unlimited", "default": 1, "type": "integer", "minimum": 0, "maximum": 1000 }, "auto_accept": { "default": false, "type": "boolean" }, "expires_in_days": { "default": 14, "type": "integer", "minimum": 1, "maximum": 90 } }, "additionalProperties": false, "examples": [ { "label": "Pune conference 2026", "max_uses": 50, "auto_accept": true, "preset": "basic" } ] }, { "title": "request", "description": "Ask the holder of a contact card to be your contact; they approve it", "type": "object", "properties": { "card": { "type": "string", "minLength": 1, "maxLength": 16384 }, "note": { "type": "string", "maxLength": 1024 } }, "required": [ "card" ], "additionalProperties": false, "examples": [ { "card": "BEGIN:VCARD…", "note": "we met at the Pune meetup" } ] } ] } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_identity_read **Read the identity.** Accounts, this identity, its card, certificate, audit chain, storage and grants, a wallet request, and its integrations and what each exposes. `readOnlyHint: true` · `openWorldHint: false` | Action | What it does | Arguments | Scope | /v1 operation | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------- | -------------------------- | | `accounts` | The identities this connection may act as; the acting one is marked, another is reached with ?identity= | — | `identity:read` | `listIdentities` | | `card` | The signed card peers receive | — | `identity:read` | `getIdentityCard` | | `certificate` | Certificate state | — | `identity:read` | `getCertificate` | | `audit` | The audit chain, newest first | `limit`?, `subject`?, `action_like`? | `audit:read` | `listIdentityAudit` | | `integrations` | Connected integrations and their health | — | `integrations:read` | `listIntegrations` | | `identity` | This identity: slug, root, address, status and whether it is certified | — | `identity:read` | `getIdentity` | | `storage` | Bytes this identity holds, and the ceiling it is held against | — | `identity:read` | `getStorage` | | `settings` | Your status, accept_new_hosts and moved_away_at, as last set | — | `identity:read` | `getIdentitySettings` | | `grants` | Which owners may act as this identity; several is a shared inbox | — | `identity:read` | `listIdentityGrants` | | `wallet_request` | What the wallet did with a request: still open, the chain it issued, or why not | `code` | `identity:read` | `readWalletRequest` | | `export_report` | What this identity’s export would say of itself: each import ceiling it passes, and every message it leaves out, by id and why | — | `identity:read` | `getExportReport` | | `export` | This identity’s export zip, unencrypted, base64, up to 5 MiB; send acknowledge: “unencrypted”; the owner approves it in the portal | `acknowledge`? | `export:read` | `exportIdentity` | | `exposure` | What an integration offers, and which of its tools contacts may reach | `integration` | `integrations:read` | `getExposure` | | `integration_catalogue` | The MCP servers that connect in one click | — | `integrations:read` | `listIntegrationCatalogue` | batondeck_identity_read input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "accounts", "card", "certificate", "audit", "integrations", "identity", "storage", "settings", "grants", "wallet_request", "export_report", "export", "exposure", "integration_catalogue" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "accounts", "description": "The identities this connection may act as; the acting one is marked, another is reached with ?identity=", "type": "object", "additionalProperties": false }, { "title": "card", "description": "The signed card peers receive", "type": "object", "additionalProperties": false }, { "title": "certificate", "description": "Certificate state", "type": "object", "additionalProperties": false }, { "title": "audit", "description": "The audit chain, newest first", "type": "object", "properties": { "limit": { "default": 100, "type": "integer", "minimum": 1, "maximum": 500 }, "subject": { "type": "string", "maxLength": 128 }, "action_like": { "description": "only actions containing this", "type": "string", "maxLength": 64 } }, "additionalProperties": false, "examples": [ { "limit": 50 } ] }, { "title": "integrations", "description": "Connected integrations and their health", "type": "object", "additionalProperties": false }, { "title": "identity", "description": "This identity: slug, root, address, status and whether it is certified", "type": "object", "additionalProperties": false }, { "title": "storage", "description": "Bytes this identity holds, and the ceiling it is held against", "type": "object", "additionalProperties": false }, { "title": "settings", "description": "Your status, accept_new_hosts and moved_away_at, as last set", "type": "object", "additionalProperties": false }, { "title": "grants", "description": "Which owners may act as this identity; several is a shared inbox", "type": "object", "additionalProperties": false }, { "title": "wallet_request", "description": "What the wallet did with a request: still open, the chain it issued, or why not", "type": "object", "properties": { "code": { "type": "string", "description": "the code this acts on" } }, "required": [ "code" ], "additionalProperties": false, "examples": [ { "code": "wr_1" } ] }, { "title": "export_report", "description": "What this identity's export would say of itself: each import ceiling it passes, and every message it leaves out, by id and why", "type": "object", "additionalProperties": false }, { "title": "export", "description": "This identity's export zip, unencrypted, base64, up to 5 MiB; send acknowledge: \"unencrypted\"; the owner approves it in the portal", "type": "object", "properties": { "acknowledge": { "description": "Must be \"unencrypted\": This file is not encrypted. Anyone who gets it can read your contact list and all your conversations and files. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported.", "type": "string" } }, "additionalProperties": false, "examples": [ { "acknowledge": "unencrypted" } ] }, { "title": "exposure", "description": "What an integration offers, and which of its tools contacts may reach", "type": "object", "properties": { "integration": { "type": "string", "description": "the name this acts on" } }, "required": [ "integration" ], "additionalProperties": false, "examples": [ { "integration": "calendar" } ] }, { "title": "integration_catalogue", "description": "The MCP servers that connect in one click", "type": "object", "additionalProperties": false } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_identity_change **Change the identity.** Whether a contact at a new address is re-pinned without asking; an integration’s exposure, sign-in and disconnection; install a wallet’s chain, move, import, delete the identity, or take back a grant. The last five wait for the owner’s approval in the portal. `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------- | ------------------------ | | `exposure` | Replace which of an integration’s tools contacts may reach | `integration`, `entries` | `integrations:write` | `setExposure` | | `settings` | Your status, which decides what contacts read; whether a contact at a new address is re-pinned without asking (auto) or held (ask); or, alone, moved_away (no renewal reminders) | `accept_new_hosts`?, `status`?, `moved_away`? | `identity:manage` | `updateIdentitySettings` | | `revoke_grant` | Take back an owner’s access to this identity; the owner approves it in the portal | `owner_id` | `identity:share` | `revokeIdentityGrant` | | `install_leaf` | Install the chain the wallet issued; the identity answers with the new key | `chain`, `credential_id`?, `backup_verified`? | `identity:lifecycle` | `installLeaf` | | `move_address` | Switch to the address the current leaf names; contacts are told | `address` | `identity:lifecycle` | `moveIdentityAddress` | | `cancel_import` | Close an open review of an export zip by its digest: the uploaded file goes, and nothing else changes | `digest` | `identity:lifecycle` | `cancelImportReview` | | `import_archive` | Take in the export zip a review holds, named by its digest; the owner approves it in the portal, shown the rows | `digest` | `identity:lifecycle` | `importArchive` | | `delete_identity` | Erase this identity, its messages and its address, at once; the owner approves it in the portal | — | `identity:lifecycle` | `deleteIdentity` | | `authorize_integration` | Start an integration’s OAuth sign-in; answers the URL the person opens | `integration`, `scope`? | `integrations:write` | `authorizeIntegration` | | `disconnect` | Disconnect an integration and forget its credential | `integration` | `integrations:write` | `deleteIntegration` | batondeck_identity_change input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "exposure", "settings", "revoke_grant", "install_leaf", "move_address", "cancel_import", "import_archive", "delete_identity", "authorize_integration", "disconnect" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "exposure", "description": "Replace which of an integration's tools contacts may reach", "type": "object", "properties": { "integration": { "type": "string", "description": "the name this acts on" }, "entries": { "type": "array", "items": { "type": "object", "properties": { "tool": { "type": "string" }, "mode": { "type": "string", "enum": [ "passthrough", "mapped", "agent" ] } }, "required": [ "tool", "mode" ], "additionalProperties": {} } } }, "required": [ "integration", "entries" ], "additionalProperties": false, "examples": [ { "integration": "calendar", "entries": [ { "tool": "list_events", "mode": "passthrough", "exposed_name": "calendar_events" } ] } ] }, { "title": "settings", "description": "Your status, which decides what contacts read; whether a contact at a new address is re-pinned without asking (auto) or held (ask); or, alone, moved_away (no renewal reminders)", "type": "object", "properties": { "accept_new_hosts": { "type": "string", "enum": [ "auto", "ask" ] }, "status": { "type": "string", "enum": [ "auto", "available", "not_available", "disabled" ] }, "moved_away": { "type": "boolean" } }, "additionalProperties": false, "examples": [ { "accept_new_hosts": "ask" } ] }, { "title": "revoke_grant", "description": "Take back an owner's access to this identity; the owner approves it in the portal", "type": "object", "properties": { "owner_id": { "type": "string", "description": "the ownerId this acts on" } }, "required": [ "owner_id" ], "additionalProperties": false, "examples": [ { "owner_id": "U-colleague" } ] }, { "title": "install_leaf", "description": "Install the chain the wallet issued; the identity answers with the new key", "type": "object", "properties": { "chain": { "minItems": 2, "maxItems": 2, "type": "array", "items": { "type": "string", "minLength": 1 } }, "credential_id": { "anyOf": [ { "type": "string", "minLength": 1, "maxLength": 512 }, { "type": "null" } ] }, "backup_verified": { "anyOf": [ { "type": "boolean" }, { "type": "null" } ] } }, "required": [ "chain" ], "additionalProperties": false, "examples": [ { "chain": [ "MIIB…leaf", "MIIB…root" ] } ] }, { "title": "move_address", "description": "Switch to the address the current leaf names; contacts are told", "type": "object", "properties": { "address": { "type": "string", "minLength": 3, "maxLength": 300 } }, "required": [ "address" ], "additionalProperties": false, "examples": [ { "address": "alex.batondeck.com" } ] }, { "title": "cancel_import", "description": "Close an open review of an export zip by its digest: the uploaded file goes, and nothing else changes", "type": "object", "properties": { "digest": { "type": "string", "description": "the digest this acts on" } }, "required": [ "digest" ], "additionalProperties": false, "examples": [ { "digest": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } ] }, { "title": "import_archive", "description": "Take in the export zip a review holds, named by its digest; the owner approves it in the portal, shown the rows", "type": "object", "properties": { "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } }, "required": [ "digest" ], "additionalProperties": false, "examples": [ { "digest": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } ] }, { "title": "delete_identity", "description": "Erase this identity, its messages and its address, at once; the owner approves it in the portal", "type": "object", "additionalProperties": false }, { "title": "authorize_integration", "description": "Start an integration's OAuth sign-in; answers the URL the person opens", "type": "object", "properties": { "integration": { "type": "string", "description": "the name this acts on" }, "scope": { "type": "string", "maxLength": 512 } }, "required": [ "integration" ], "additionalProperties": false, "examples": [ { "integration": "calendar" } ] }, { "title": "disconnect", "description": "Disconnect an integration and forget its credential", "type": "object", "properties": { "integration": { "type": "string", "description": "the name this acts on" } }, "required": [ "integration" ], "additionalProperties": false, "examples": [ { "integration": "calendar" } ] } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_identity_write **Connect, share or open a wallet request.** Connect an upstream MCP server; let another owner act as this identity; open a request for your wallet to sign, or mint a signing request. Sharing and signing wait for the owner’s approval in the portal. `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------- | --------------------- | | `grant` | Let another owner of the workspace act as this identity; the owner approves it in the portal | `owner_id` | `identity:share` | `grantIdentity` | | `open_wallet_request` | Open a request for your wallet to sign a new key (signup, renew, move, or return: a renewal that also hands back your wallet’s contact book); answers the page you sign on | `purpose`, `endpoint`? | `identity:lifecycle` | `openWalletRequest` | | `csr` | Mint a key and a signing request for a wallet you run yourself | `purpose`, `endpoint`? | `identity:lifecycle` | `issueCsr` | | `review_import` | Review an export zip (base64url): its contacts and what an import would do with each, and its digest; nothing is written | `archive` | `identity:lifecycle` | `reviewImportArchive` | | `connect` | Connect an MCP server (a catalogue id, or an endpoint); answers the sign-in URL when it needs one | `catalogue`?, `slug`?, `endpoint`?, `transport`?, `auth`? | `integrations:write` | `createIntegration` | batondeck_identity_write input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "grant", "open_wallet_request", "csr", "review_import", "connect" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "grant", "description": "Let another owner of the workspace act as this identity; the owner approves it in the portal", "type": "object", "properties": { "owner_id": { "type": "string", "minLength": 1 } }, "required": [ "owner_id" ], "additionalProperties": false, "examples": [ { "owner_id": "U-colleague" } ] }, { "title": "open_wallet_request", "description": "Open a request for your wallet to sign a new key (signup, renew, move, or return: a renewal that also hands back your wallet's contact book); answers the page you sign on", "type": "object", "properties": { "purpose": { "type": "string", "enum": [ "signup", "renew", "move" ] }, "endpoint": { "type": "string", "minLength": 1, "maxLength": 512 } }, "required": [ "purpose" ], "additionalProperties": false, "examples": [ { "purpose": "renew" } ] }, { "title": "csr", "description": "Mint a key and a signing request for a wallet you run yourself", "type": "object", "properties": { "purpose": { "type": "string", "enum": [ "signup", "renew", "move" ] }, "endpoint": { "type": "string", "minLength": 1, "maxLength": 512 } }, "required": [ "purpose" ], "additionalProperties": false, "examples": [ { "purpose": "renew" } ] }, { "title": "review_import", "description": "Review an export zip (base64url): its contacts and what an import would do with each, and its digest; nothing is written", "type": "object", "properties": { "archive": { "type": "string", "minLength": 1 } }, "required": [ "archive" ], "additionalProperties": false, "examples": [ { "archive": "UEsDBBQ…" } ] }, { "title": "connect", "description": "Connect an MCP server (a catalogue id, or an endpoint); answers the sign-in URL when it needs one", "type": "object", "properties": { "catalogue": { "type": "string", "maxLength": 64 }, "slug": { "type": "string", "minLength": 2, "maxLength": 32 }, "endpoint": { "type": "string", "format": "uri" }, "transport": { "type": "string", "enum": [ "streamable-http", "sse" ] }, "auth": { "anyOf": [ { "type": "object", "properties": { "header": { "type": "string", "minLength": 1, "maxLength": 64 }, "value": { "type": "string", "minLength": 1, "maxLength": 4096 } }, "required": [ "header", "value" ] }, { "type": "null" } ] } }, "additionalProperties": false, "examples": [ { "catalogue": "linear" } ] } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_workspace_read **Read the workspace.** The workspace, its settings, residency, single sign-on, plan, billing, usage, members, audit, hostnames and a custom domain, agent keys, webhooks and their deliveries, exports, and an export’s archive (which waits for the owner’s approval in the portal). `readOnlyHint: true` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------------------ | ----------------------- | | `settings` | The workspace’s settings, and why the platform fixes the ones it fixes | — | `workspace:read` | `listSettings` | | `workspace` | The workspace: name, status, plan, jurisdiction, any pause or deletion hold | — | `workspace:read` | `getWorkspace` | | `plan` | The plan and every entitlement in force, with where each comes from | — | `billing:read` | `getWorkspacePlan` | | `billing` | The subscription as billing knows it | — | `billing:read` | `getBilling` | | `usage` | Metered usage by day | `limit`? | `billing:read` | `listUsage` | | `members` | The owners in the workspace and which of them administer it | — | `members:read` | `listMembers` | | `audit` | The workspace’s audit: its chain and its events | `limit`? | `audit:read` | `listWorkspaceAudit` | | `domains` | The hostnames the workspace answers on | — | `workspace:read` | `listDomains` | | `keys` | Agent keys: name, prefix, scopes, who made it, last use; never a key. The owner’s own, or every key for an admin | — | `keys:read` | `listApiKeys` | | `webhooks` | Webhook endpoints and their state; never a secret | — | `credentials:read` | `listWebhooks` | | `webhook_deliveries` | An endpoint’s delivery log, newest first | `webhook_id`, `limit`? | `credentials:read` | `listWebhookDeliveries` | | `domain` | One custom domain: its status, the DNS record to create and what is still needed, read live | `hostname` | `workspace:read` | `getCustomDomain` | | `residency` | Where the workspace’s data is held, and whether the residency guarantee is in effect | — | `workspace:read` | `getResidency` | | `sso` | Whether the workspace requires single sign-on, its connections and its domains | — | `workspace:read` | `getSso` | | `download_export` | The workspace export zip, unencrypted, base64, up to 5 MiB; send acknowledge: “unencrypted”; the owner approves it in the portal | `acknowledge`? | `export:read` | `exportWorkspace` | batondeck_workspace_read input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "settings", "workspace", "plan", "billing", "usage", "members", "audit", "domains", "keys", "webhooks", "webhook_deliveries", "domain", "residency", "sso", "download_export" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "settings", "description": "The workspace's settings, and why the platform fixes the ones it fixes", "type": "object", "additionalProperties": false }, { "title": "workspace", "description": "The workspace: name, status, plan, jurisdiction, any pause or deletion hold", "type": "object", "additionalProperties": false }, { "title": "plan", "description": "The plan and every entitlement in force, with where each comes from", "type": "object", "additionalProperties": false }, { "title": "billing", "description": "The subscription as billing knows it", "type": "object", "additionalProperties": false }, { "title": "usage", "description": "Metered usage by day", "type": "object", "properties": { "limit": { "default": 200, "type": "integer", "minimum": 1, "maximum": 1000 } }, "additionalProperties": false, "examples": [ { "limit": 30 } ] }, { "title": "members", "description": "The owners in the workspace and which of them administer it", "type": "object", "additionalProperties": false }, { "title": "audit", "description": "The workspace's audit: its chain and its events", "type": "object", "properties": { "limit": { "default": 100, "type": "integer", "minimum": 1, "maximum": 500 } }, "additionalProperties": false, "examples": [ { "limit": 50 } ] }, { "title": "domains", "description": "The hostnames the workspace answers on", "type": "object", "additionalProperties": false }, { "title": "keys", "description": "Agent keys: name, prefix, scopes, who made it, last use; never a key. The owner's own, or every key for an admin", "type": "object", "additionalProperties": false }, { "title": "webhooks", "description": "Webhook endpoints and their state; never a secret", "type": "object", "additionalProperties": false }, { "title": "webhook_deliveries", "description": "An endpoint's delivery log, newest first", "type": "object", "properties": { "webhook_id": { "type": "string", "description": "the id this acts on" }, "limit": { "default": 50, "type": "integer", "minimum": 1, "maximum": 200 } }, "required": [ "webhook_id" ], "additionalProperties": false, "examples": [ { "webhook_id": "whe_1", "limit": 20 } ] }, { "title": "domain", "description": "One custom domain: its status, the DNS record to create and what is still needed, read live", "type": "object", "properties": { "hostname": { "type": "string", "description": "the hostname this acts on" } }, "required": [ "hostname" ], "additionalProperties": false, "examples": [ { "hostname": "batondeck.example.com" } ] }, { "title": "residency", "description": "Where the workspace's data is held, and whether the residency guarantee is in effect", "type": "object", "additionalProperties": false }, { "title": "sso", "description": "Whether the workspace requires single sign-on, its connections and its domains", "type": "object", "additionalProperties": false }, { "title": "download_export", "description": "The workspace export zip, unencrypted, base64, up to 5 MiB; send acknowledge: \"unencrypted\"; the owner approves it in the portal", "type": "object", "properties": { "acknowledge": { "description": "Must be \"unencrypted\": This file is not encrypted. Anyone who gets it can read your contact list and all your conversations and files. It holds no keys, so it cannot be used to speak as you. Keep it where you keep private documents, and delete it once it has been imported.", "type": "string" } }, "additionalProperties": false, "examples": [ { "acknowledge": "unencrypted" } ] } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_workspace_change **Change the workspace.** Change a setting or its name; pause, resume, schedule or cancel its deletion; require single sign-on; check or remove a custom domain; revoke a key; rotate, pause or remove a webhook. Pausing, deleting and single sign-on wait for the owner’s approval in the portal. `readOnlyHint: false` · `destructiveHint: true` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ----------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------- | -------------------- | -------------------------- | | `update` | Rename the workspace, or set how long a sign-in lasts | `name`?, `session_max_age_hours`? | `workspace:manage` | `updateWorkspace` | | `pause` | Pause the workspace (restriction of processing); the owner approves it in the portal | `reason`? | `workspace:admin` | `pauseWorkspace` | | `resume` | Lift your own pause | — | `workspace:admin` | `resumeWorkspace` | | `request_deletion` | Schedule the workspace for deletion after a seven-day hold; the owner approves it in the portal | `reason`? | `workspace:admin` | `requestWorkspaceDeletion` | | `cancel_deletion` | Cancel a scheduled deletion inside its seven days | — | `workspace:admin` | `cancelWorkspaceDeletion` | | `revoke_key` | Revoke an agent key: the owner’s own, or any for an admin; it stops working at once | `key_id` | `keys:manage` | `revokeApiKey` | | `rotate_webhook_secret` | Rotate an endpoint’s signing secret; the new one is in the answer once | `webhook_id` | `credentials:manage` | `rotateWebhookSecret` | | `set_webhook_status` | Pause an endpoint, or resume one | `webhook_id`, `status` | `credentials:manage` | `setWebhookStatus` | | `delete_webhook` | Remove a webhook endpoint; its delivery log stays | `webhook_id` | `credentials:manage` | `deleteWebhook` | | `check_domain` | Check a custom domain now, and record the result | `hostname` | `workspace:manage` | `checkCustomDomain` | | `remove_domain` | Remove a custom domain no identity lives at | `hostname` | `workspace:manage` | `removeCustomDomain` | | `set_sso_required` | Require single sign-on, or stop requiring it; the owner approves it in the portal | `required` | `workspace:admin` | `setSsoRequired` | batondeck_workspace_change input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "update", "pause", "resume", "request_deletion", "cancel_deletion", "revoke_key", "rotate_webhook_secret", "set_webhook_status", "delete_webhook", "check_domain", "remove_domain", "set_sso_required" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "update", "description": "Rename the workspace, or set how long a sign-in lasts", "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 64 }, "session_max_age_hours": { "anyOf": [ { "type": "integer", "minimum": 1, "maximum": 168 }, { "type": "null" } ] } }, "additionalProperties": false, "examples": [ { "name": "Pune Studio" } ] }, { "title": "pause", "description": "Pause the workspace (restriction of processing); the owner approves it in the portal", "type": "object", "properties": { "reason": { "type": "string", "maxLength": 500 } }, "additionalProperties": false, "examples": [ { "reason": "a dispute with a contact" } ] }, { "title": "resume", "description": "Lift your own pause", "type": "object", "additionalProperties": false }, { "title": "request_deletion", "description": "Schedule the workspace for deletion after a seven-day hold; the owner approves it in the portal", "type": "object", "properties": { "reason": { "type": "string", "maxLength": 500 } }, "additionalProperties": false, "examples": [ { "reason": "closing the studio" } ] }, { "title": "cancel_deletion", "description": "Cancel a scheduled deletion inside its seven days", "type": "object", "additionalProperties": false }, { "title": "revoke_key", "description": "Revoke an agent key: the owner's own, or any for an admin; it stops working at once", "type": "object", "properties": { "key_id": { "type": "string", "description": "the id this acts on" } }, "required": [ "key_id" ], "additionalProperties": false, "examples": [ { "key_id": "key_1" } ] }, { "title": "rotate_webhook_secret", "description": "Rotate an endpoint's signing secret; the new one is in the answer once", "type": "object", "properties": { "webhook_id": { "type": "string", "description": "the id this acts on" } }, "required": [ "webhook_id" ], "additionalProperties": false, "examples": [ { "webhook_id": "whe_1" } ] }, { "title": "set_webhook_status", "description": "Pause an endpoint, or resume one", "type": "object", "properties": { "webhook_id": { "type": "string", "description": "the id this acts on" }, "status": { "type": "string", "enum": [ "active", "paused" ] } }, "required": [ "webhook_id", "status" ], "additionalProperties": false, "examples": [ { "webhook_id": "whe_1", "status": "paused" } ] }, { "title": "delete_webhook", "description": "Remove a webhook endpoint; its delivery log stays", "type": "object", "properties": { "webhook_id": { "type": "string", "description": "the id this acts on" } }, "required": [ "webhook_id" ], "additionalProperties": false, "examples": [ { "webhook_id": "whe_1" } ] }, { "title": "check_domain", "description": "Check a custom domain now, and record the result", "type": "object", "properties": { "hostname": { "type": "string", "description": "the hostname this acts on" } }, "required": [ "hostname" ], "additionalProperties": false, "examples": [ { "hostname": "batondeck.example.com" } ] }, { "title": "remove_domain", "description": "Remove a custom domain no identity lives at", "type": "object", "properties": { "hostname": { "type": "string", "description": "the hostname this acts on" } }, "required": [ "hostname" ], "additionalProperties": false, "examples": [ { "hostname": "batondeck.example.com" } ] }, { "title": "set_sso_required", "description": "Require single sign-on, or stop requiring it; the owner approves it in the portal", "type": "object", "properties": { "required": { "type": "boolean" } }, "required": [ "required" ], "additionalProperties": false, "examples": [ { "required": true } ] } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ### batondeck_workspace_write **Create in the workspace.** Mint an agent key, register a webhook, start an export, or open a plan checkout, billing or single sign-on link — each waiting for the owner’s approval in the portal — or add a custom domain. `readOnlyHint: false` · `destructiveHint: false` · `idempotentHint: false` · `openWorldHint: true` | Action | What it does | Arguments | Scope | /v1 operation | | ----------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | -------------------- | --------------------- | | `checkout` | A Stripe Checkout link for a paid plan; the owner approves it in the portal first | `plan` | `billing:manage` | `openCheckout` | | `billing_portal` | A link to Stripe’s customer portal; the owner approves it in the portal first | — | `billing:manage` | `openBillingPortal` | | `mint_key` | Mint an agent key; the owner approves it in the portal, and the key is in the answer once | `name`, `scopes`?, `everything`?, `identity_ids`?, `expires_in_days`? | `keys:manage` | `mintApiKey` | | `create_webhook` | Register a webhook endpoint; the owner approves it in the portal, and the secret is in the answer once | `url`, `events`? | `credentials:manage` | `createWebhook` | | `add_domain` | Add a custom domain; answers the DNS records to create | `hostname` | `workspace:manage` | `addCustomDomain` | | `sso_portal_link` | A short-lived link to set up single sign-on or verify a domain; the owner approves it in the portal | `intent` | `workspace:admin` | `createSsoPortalLink` | batondeck_workspace_write input schema ```json { "type": "object", "properties": { "action": { "type": "string", "enum": [ "checkout", "billing_portal", "mint_key", "create_webhook", "add_domain", "sso_portal_link" ] }, "arguments": { "type": "object", "description": "the arguments of the action named by `action`", "anyOf": [ { "title": "checkout", "description": "A Stripe Checkout link for a paid plan; the owner approves it in the portal first", "type": "object", "properties": { "plan": { "type": "string", "enum": [ "pro", "team", "enterprise" ] } }, "required": [ "plan" ], "additionalProperties": false, "examples": [ { "plan": "team" } ] }, { "title": "billing_portal", "description": "A link to Stripe's customer portal; the owner approves it in the portal first", "type": "object", "additionalProperties": false }, { "title": "mint_key", "description": "Mint an agent key; the owner approves it in the portal, and the key is in the answer once", "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 100 }, "scopes": { "minItems": 1, "type": "array", "items": { "type": "string" } }, "everything": { "type": "boolean" }, "identity_ids": { "type": "array", "items": { "type": "string" } }, "expires_in_days": { "type": "integer", "minimum": 1, "maximum": 365 } }, "required": [ "name" ], "additionalProperties": false, "examples": [ { "name": "nightly export", "scopes": [ "batondeck:workspace:read" ] } ] }, { "title": "create_webhook", "description": "Register a webhook endpoint; the owner approves it in the portal, and the secret is in the answer once", "type": "object", "properties": { "url": { "type": "string", "minLength": 1 }, "events": { "default": [], "type": "array", "items": { "type": "string" } } }, "required": [ "url" ], "additionalProperties": false, "examples": [ { "url": "https://hooks.example.com/hdtp", "events": [ "message.received" ] } ] }, { "title": "add_domain", "description": "Add a custom domain; answers the DNS records to create", "type": "object", "properties": { "hostname": { "type": "string", "minLength": 3, "maxLength": 253 } }, "required": [ "hostname" ], "additionalProperties": false, "examples": [ { "hostname": "batondeck.example.com" } ] }, { "title": "sso_portal_link", "description": "A short-lived link to set up single sign-on or verify a domain; the owner approves it in the portal", "type": "object", "properties": { "intent": { "type": "string", "enum": [ "sso", "domain_verification" ] } }, "required": [ "intent" ], "additionalProperties": false, "examples": [ { "intent": "sso" } ] } ] }, "confirmation": { "type": "string", "description": "for an act that needs the owner's approval: the confirmation a step_up_required answer named, once they have approved it in the portal" } }, "required": [ "action" ], "additionalProperties": false } ``` ## Resources | URI | Name | What it holds | | -------------------- | ------------------------------- | ------------------------------------------------------------- | | `hdtp://inbox` | inbox | Unread counts per thread, for a badge that costs no tool call | | `hdtp://requests` | contact requests | Contacts waiting for the owner to approve them | | `hdtp://pending` | pending agent-answered requests | Calls a contact made that this agent is expected to answer | | `hdtp://thread/{id}` | thread | One conversation’s state: unread count and when it last moved | # Webhooks > We POST to your URL when something happens on your workspace. We POST to your URL when something happens on your workspace. This page is the contract: what arrives, how to verify it, and — the part worth reading twice — **what we do not promise**. ## Until webhooks are switched on for your workspace Webhooks are off until we switch them on, per workspace or for everyone. You can register an endpoint and rotate its secret while they are off, but nothing is delivered: no request is made and the endpoint’s delivery log stays empty. The **Webhooks** section under **Apps and keys** says when this is the case, and `GET /v1/workspace/webhooks` answers `"enabled": false`. Once they are on, events from that moment are delivered; nothing that happened while they were off is sent later. On staging (`app-stg.batondeck.com`) webhooks are on by default, so an endpoint registered there receives deliveries from the start; we can still switch them off for a workspace, and `"enabled"` says which. Production starts with them off. ## One attempt. No retries. **A delivery is attempted exactly once.** If your endpoint is slow, down, or answers anything outside 2xx, that event is not sent again. The event passes through our own queue on its way to you, and that queue does not retry it either: no second attempt, no backoff, and no dead-letter replay. That is a deliberate choice, and the trade is yours to plan around: * **What you get**: a webhook sent soon after the event, not at the moment of it — the event waits in our queue first, usually for a few seconds and occasionally for much longer, because the queue does not bound that wait; then the one attempt is made — and a log in the portal showing every attempt, its HTTP status, its latency and the first 500 bytes your server answered with. * **What you do not get**: a guarantee that every event reaches you. A network blip on your side loses that event. **If you need certainty, treat the webhook as a hint and `/v1` as the record.** Poll the resource the event names — `GET /v1/identities/:slug/threads`, `/contacts`, and so on — and reconcile. A webhook tells you *when* to look; the API tells you *what is true*. If you need a queue with retries, put one in front of your own endpoint: accept the POST, enqueue it yourself, answer 200 immediately. We may add at-least-once delivery later. We will not remove this page’s promise without telling you. ## Verifying a delivery Every request carries: | Header | What it is | | ------------------------ | --------------------------------------------------------------- | | `X-BatonDeck-Signature` | `t=,v1=` — see below | | `X-BatonDeck-Event-Id` | Stable per event. The same id never means two different things. | | `X-BatonDeck-Event-Type` | e.g. `message.received` | | `X-BatonDeck-Delivery` | This attempt. Quote it if you contact us. | The signature is HMAC-SHA256 over `` `${t}.${rawBody}` `` with your endpoint’s signing secret, hex-encoded. Verify it against the **raw** body, before any JSON parsing. ```js import { createHmac, timingSafeEqual } from 'node:crypto' export function verify(secret, rawBody, header, nowSeconds = Date.now() / 1000) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))) // Reject anything older than five minutes, or a captured delivery can be replayed // against you for ever. This check is not optional. if (Math.abs(nowSeconds - Number(parts.t)) > 300) return false const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex') // Constant time. A byte-by-byte compare leaks the signature to a patient attacker. const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '') return a.length === b.length && timingSafeEqual(a, b) } ``` **During a secret rotation the header carries two `v1` values** — the new secret first, the old one second — for 24 hours. Accept the delivery if *either* verifies, and you can deploy the new secret whenever suits you inside that window. ## What is in the body Ids and metadata. **Never a message body, media, a contact card, or key material.** ```json { "id": "evt_9f8c…", "type": "message.received", "created_at": 1757462400000, "identity": "alice", "data": { "thread_id": "th-1", "contact": "sha256:…" } } ``` Use the ids to fetch what you need from `/v1` with an API key. That is the same rule as the retry policy above, for the same reason: the webhook is a notification, not a copy of your data. ## Endpoints Registered under **Apps and keys** in the portal. * **https only.** A signature over plaintext still hands the payload to anyone on the path. * **A hostname, not an IP address**, and not a name that only resolves on your own network. Our requests come from Cloudflare, so a private address would never reach you. * **From the moment it is registered.** An endpoint is sent the events that happen after it is created, never one from before, even when that event is still on its way to you. * **An event filter, or none** — no filter means every event. A filter may name only events we send; a name that is reserved and not yet sent is refused rather than stored. * **Twenty consecutive failures pauses the endpoint.** We stop dialling a URL that has stopped answering; you resume it in the portal when it is fixed. The signing secret is shown once, when you create the endpoint and when you rotate it. It is stored sealed under your workspace’s key and opened only to sign a delivery; no route reads it back, so nobody here can look it up for you. Lost it? Rotate. # HDTP Gateway > The self-hosted HDTP node: one Go binary that runs a personal, permission-gated MCP server. ## The problem Two people each have an assistant. Today those assistants cannot talk to each other unless both people chose the same product — and then that product sits in the middle of every word. The fix is not another chat app. It is what email already proved: **everyone runs their own endpoint, and addresses are portable.** HDTP is that idea for agents, and `hdtp-gateway` is the endpoint you run. ```mermaid graph LR AA["Alice's agent"] -->|"owner MCP
bearer token"| AN["Alice's node"] AN -->|"mTLS + sealed envelope"| BN["Bob's node"] BN -->|"owner MCP"| BA["Bob's agent"] AN -.->|"vCard: endpoint + key"| BN ``` No server in that picture has to be trusted by both parties. Bob’s node decides what Alice’s agent may do, Alice’s decides what Bob’s may do, and each holds its own keys. ## What makes it different * **You are a keypair, not an account.** Your identity is the SHA-256 of your public key. Nothing to sign up for, no username, no provider to be locked out of. * **Every capability is an MCP tool behind a per-contact switchboard.** A close friend can book time. A stranger who redeemed a one-time invite can send one message and discover nothing else — `tools/list` is filtered per caller. * **Sealed end to end.** Payloads are HPKE-sealed and signed, so tunnels and edges carry ciphertext. They learn which key a message is for and when — never who sent it, and never what it says. * **It works from behind CGNAT.** Direct, Tailscale, frp, ngrok, Cloudflare, or your own domain. With no inbound path at all you need one of those tunnels — or a host: there is no store-and-forward relay, because one would see every sender, recipient and timestamp for its trouble (HDTP §9). * **One binary, SQLite by default.** No cluster, no broker, no queue. Postgres when you want it. ## What a contact actually is A **vCard** — the format your phone already understands, plus two fields. Sharing your agent’s address is sharing a contact. ![Your card: a vCard carrying the HDTP certificate and seal policy](/images/gateway/card.png) `X-HDTP-CERT` is one certificate — the **leaf** your own root issued this host — and it carries everything: where to reach you, the key to seal to, the fingerprint of the root that is your identity, and how long it is good for. What a contact pins is that root, never the host’s key, which is what lets you change hosts without changing who you are. `X-HDTP-SEAL` says whether to seal. That is the whole address book. # Add someone who invited you > Mint a token for your agent, over the admin socket: Mint a token for your agent, over the admin socket: ```plaintext hdtp-gateway token create -owner -label "my agent" ``` Then, from your agent on the owner MCP: ```json {"name": "add_contact", "arguments": {"account_id": "…", "invite_url": "https://their.example/i/abc123"}} ``` Your node fetches their card, checks that the key hashes to the fingerprint the card claims, verifies the card’s signature, and only then redeems — pinning them. # Be reachable > Then check your work: | You have | Use | | ----------------------- | -------------------------------------------------------------------------------------------------- | | A public IP | `direct` | | A tailnet | `tailscale`, with Funnel for the public side | | A VPS you already run | `frp`, or the built-in **ingress role** on your own domain | | Neither, but an account | `ngrok`, `cloudflare` | | No inbound path at all | a tunnel from the rows above, or let a provider host the identity under a leaf you issue (HDTP §9) | Then check your work: ```plaintext hdtp-gateway doctor ``` It derives your deployment mode, probes your endpoint, and reports what an outside caller would actually see. # Decide what each contact may do > Permissions are dotted and per-contact — message.text, message.media, calendar.availability, calendar.book, integration. — set from a preset or one by one. Permissions are dotted and per-contact — `message.text`, `message.media`, `calendar.availability`, `calendar.book`, `integration.` — set from a preset or one by one. Availability answers with at most five policy-filtered slots and never your raw free/busy. # Expose a tool from another MCP server > Connect an upstream server (streamable-HTTP, SSE, or a supervised stdio child) and publish only the tools you choose, either passthrough or mapped onto HDTP's own vocabulary through a recipe. Connect an upstream server (streamable-HTTP, SSE, or a supervised stdio child) and publish **only the tools you choose**, either passthrough or mapped onto HDTP’s own vocabulary through a recipe. Exposing a write-capable tool takes a recorded acknowledgment, and an upstream that changes underneath you is narrowed, never silently widened. # Invite someone *Invites* → **Create** gives you a `/i/` link and a QR to send however you like. An invite is server-side state: it can expire, be limited to one use, carry a permission preset, and be revoked. Nothing sensitive rides in the URL itself. # Leave this node > When you have moved an identity to another host, tell this one to forget it: When you have moved an identity to another host, tell this one to forget it: ```plaintext hdtp-gateway account leave -slug me # shows what it would erase, erases nothing hdtp-gateway account leave -slug me -yes # erases it ``` It refuses an identity this node serves at its own address for it right now — which is what “delete it at the old host” would name after a move to another address on this same node — unless you add `-force-current`. It erases every record of the identity at once: its contacts, chats, media no other identity here uses, invites, integrations and their OAuth client credentials, tokens scoped to it, its settings, and every leaf key this node held for it. The live node stops answering for it straight away, as for an address it never served. The audit trail is the one thing the erase does not reach at once: it is append-only (by trigger) and hash-chained. HDTP §9 asks a host to “keep nothing beyond what law compels”, so the rows that name the identity by its account id — with its slug and its contacts’ fingerprints as they wrote them — stay in the live trail for `audit_archive_after` (90 days unless you set it: `HDTP_AUDIT_ARCHIVE_AFTER=30d`, or `audit_archive_after` in the config file), long enough to review the leave on the portal’s audit page. Then the hourly sweep moves every one of them to `/audit-archive/--.jsonl` (mode 0600) and writes one `audit_archive` row that names the segment and its hashes, not the identity. (Rows you had already moved to the head archive with `audit archive -through N` stay in that file.) `hdtp-gateway audit verify` (node stopped) checks the table and the archives as one chain and reports a changed or missing archive as broken. The archive is kept. If law requires its rows to go, `hdtp-gateway audit erase-archive -file ` keeps only each row’s seq and hashes, so the chain still verifies, and records that it did (SPEC §3.11, §11.6). On SQLite the leaf keys are destroyed, not only deleted: the node zeroes deleted rows (`secure_delete`) and truncates its write-ahead log after the leave. On Postgres it cannot: a deleted row stays as a dead tuple until VACUUM reuses its space, in the write-ahead log until the segment is recycled, and in every backup — sealed under the node’s keyring, but not destroyed. SPEC §3.9 names this divergence. The address stays **reserved** until the last leaf issued for it expires (HDTP §9): until then no identity can be created under that slug here, and no signing request can name that address. The command prints each address it reserved and until when. It is refused while a move campaign for that identity is running (`account announce -slug me` says when it has finished). There is no undo, and no portal or owner-MCP button: like `import`, it needs shell access on the host. # Let your own agent run the node > The owner MCP is a second surface, separate from the public one, with 33 tools on a running node: read the inbox, send to a contact, approve or reject requests, block, unblock and remove contacts, answer a contact waiting at a new address, create, list and revoke invites, set permissions, manage integrations, query the audit chain. The owner MCP is a second surface, separate from the public one, with **33 tools** on a running node: read the inbox, send to a contact, approve or reject requests, block, unblock and remove contacts, answer a contact waiting at a new address, create, list and revoke invites, set permissions, manage integrations, query the audit chain. It requires named, revocable bearer tokens on every bind, loopback included. # Manage your passkeys, and get back in if you lose them > A passkey is the node's only login, on every bind including loopback — there is no local-access shortcut. A passkey is the node’s only login, on **every** bind including loopback — there is no local-access shortcut. So the way back in matters. All three need the node running; they talk over the admin unix socket, whose permissions are the host’s. ```plaintext hdtp-gateway passkey list # id, tag, owner hdtp-gateway passkey remove -id # refuses the last one hdtp-gateway passkey reset-wizard # a one-time link that re-opens registration ``` `reset-wizard` is the recovery path. It prints a URL valid for 24 hours and usable once: ```plaintext one-time setup URL (24h, single use): http://localhost:8080/setup?token=b0527dbe01e36eed2ef51a576b4a2e34 ``` Open it and register a new passkey. It **adds** one and removes nothing, so a device you still have keeps working. This is the only thing that re-opens the wizard once a passkey exists — reaching loopback does not, and neither does a leftover first-run token, or any local process could quietly register itself as an owner. That makes **shell access on the host the root of trust for recovery**, which is the honest trade: there is deliberately no online recovery path, no email reset, nobody to ask. Register a second passkey on another device before you need one. > Running in Docker? Prefix it: `docker compose exec hdtp-gateway hdtp-gateway passkey reset-wizard`. And sign in at `http://localhost:`, not `127.0.0.1` — a loopback portal presents itself as `localhost` because an IP is not a valid WebAuthn relying-party ID, so that is the name your passkey is bound to. # Take your data with you > Your identity is the root in your wallet. Your identity is the **root** in your wallet. It is never on this node, so nothing here *is* you. What you can take away is what is yours: your **contacts**, your **chats** and the **files** in them — one identity at a time, in one zip that the cloud and any other HDTP host read too. ```plaintext hdtp-gateway export -slug alina -out alina.zip hdtp-gateway import alina.zip -slug alina # shows what it would write, writes nothing hdtp-gateway import alina.zip -slug alina -yes # writes it ``` Both are offline (stop the node first) and both need host shell access. They work on SQLite and on Postgres alike, because the file is written through the node’s own store rather than copied out of a database. **The file is not encrypted.** Anyone who gets it can read your contact list and all your conversations and files; `export` says so before it writes. It holds no keys, so it cannot be used to speak as you — no leaf’s key, not the node’s master key — and no settings, integration credentials, tokens, passkeys, invites or audit history: those belong to the host that made them. Keep it where you keep private documents, and delete it once it has been imported. **An import checks the whole file before it writes anything,** and refuses it whole at the first fault. Into a slug that is not here, the identity arrives with its root and nothing more — **not served** until your wallet issues this host a leaf; into the identity it belongs to, it merges, and every pin this host already holds stands. Either way it ends with a request for a new leaf that the import makes itself (complete it in your web wallet from the portal, or with the CLI wallet from the request it prints; `account certificate` and `doctor` keep naming it until it is done), and installing that leaf tells the imported contacts where you are now. An export reads its own file back before it reports it, and warns when the file is more than BatonDeck takes back in. # Your wallet > Your identity is a root, and the root lives in your wallet, never on this node. Your identity is a **root**, and the root lives in your **wallet**, never on this node. The node holds a **leaf**: a certificate your root issues to this host, for one address, until one date (HDTP §9, §14.1). The node makes the request, the wallet signs it, the node installs the answer: ```plaintext hdtp-gateway account csr -slug me > me.csr # the request: this host's key and address hdtp id issue --vault me.hdtp-vault.json --csr me.csr --chain-out chain.pem hdtp-gateway account install-leaf -slug me -chain chain.pem ``` The wallet a self-hoster uses is the `hdtp` CLI from hdtp-identity. `hdtp id create` makes the root once, in a vault file under a passphrase; `hdtp id issue` shows what a request names and asks before it signs. `account csr` prints only the request on its standard output (what it is for goes to standard error), and `install-leaf` takes the two certificates `--chain-out` writes, leaf then root. `account csr -slug me -purpose renew` asks for the next leaf before this one runs out, and `-purpose move` for a leaf at a new address. The root never comes to this node, and nothing here can make one: losing the vault and its passphrase is losing that identity. **A web wallet, from the portal.** Once an identity has its first leaf, *Identity → Sign with my web wallet* asks a web wallet (`HDTP_WALLET_URL`, `https://ceremony.hdtp.io` by default) for the next one: a renewal, or a move when the address changes. The page shows what it will ask and changes nothing until you continue; a request already waiting is replaced only if you confirm it. The wallet sends its answer back to this node’s `/wallet/return` in the same browser, and the portal installs it with your session. This is the node’s side of it. The wallet’s `/sign` page is BatonDeck’s and is not live yet, and how browsers treat a public page sending you back to `http://localhost` has not been measured, so until both are, use the `hdtp` CLI. **After a move.** When an install moves the identity to a new address, both the CLI and the portal say so, and say to run `hdtp-gateway account announce -slug me` until no contact is waiting: the previous certificate stays valid until its own date for contacts not yet told. When the identity came from another host, that is also when to delete it there. When this node itself moved to a new address, there is nothing to delete: the previous certificate goes on answering here until it expires. # Operating a node > This is the operator's page: what runs where, how the node is reached, how it is backed up, and what to do when something is lost. This is the operator’s page: what runs where, how the node is reached, how it is backed up, and what to do when something is lost. Design rationale lives in `SPEC.md`; this page only tells you what to run. ## Layout | Path (in `data_dir`, `/data` in the container) | What | | ---------------------------------------------- | ---------------------------------------------------------------------------------- | | `hdtp.db` | the SQLite store: accounts, contacts, messages, integrations, audit chain | | `blobs/` | content-addressed media | | `keyring.key` | the keyring master key (0600). **Every account’s private key is sealed under it.** | | `hdtp.lock` | held while `serve` runs; offline commands refuse to run while it is held | | `admin.sock` | the admin socket the CLI talks to while the node is running | Configuration: env `HDTP_*` > `config.json` > defaults (`hdtp-gateway doctor` prints the resolved result). The deployment **mode is derived** from the tunnel adapter, never declared (SPEC §10.1). ## Reachability | You have | Use | Mode | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | | a public port / static IP / VPS | `tunnel: direct` (default) | direct | | a Tailscale account | `tunnel: tailscale` — tsnet Funnel, free, ports 443/8443/10000 | direct | | an `frps` you run on any VPS | `tunnel: frp` — raw SNI/tcp passthrough | direct | | a paid ngrok plan | `tunnel: ngrok` — `tls://` endpoint | direct | | a Cloudflare account | `cloudflare` edge adapter (P4-05) — Cloudflare terminates TLS | edge | | a domain of your own | run a second hdtp-gateway in the **ingress role** on a VPS (below) | direct (passthrough) or edge (terminate) | | nothing inbound at all | one of the tunnels above, or a provider hosting the identity under a leaf you issue (HDTP §9). There is no relay role: it went on 2026-09-18, because a store-and-forward gateway sees every sender, recipient and timestamp | — | Direct mode keeps mTLS end to end: callers’ client certificates reach the node. Edge mode cannot — a third party terminates TLS — so the node **forces** `seal=required` and `client_cert=off`, and callers are identified by their sealed-envelope signatures. `hdtp-gateway doctor` reports the derived mode and probes your advertised endpoint; a `wrong_cert` verdict means something between the caller and the node is terminating TLS that should not be. Every carrier still sees metadata (who talks to whom, sizes, timing). Where that is itself sensitive, use `direct` on infrastructure you control (SPEC §13). ### Ingress role (own domain) On the VPS run the ingress; on the node pair with a one-time token: 1. ingress: `hdtp-gateway ingress serve --domain example.com` (P5-05 wires the command; the library is `internal/ingress`) and mint a token from its portal. 2. node: portal → *Settings → Ingress* — paste the token, pick the subdomain and mode: * **passthrough** — the ingress routes on SNI and forwards raw TLS; your node’s own certificate is what callers see (direct mode). * **terminate** — the ingress holds an ACME certificate, terminates the public TLS session, and re-originates a mutually-pinned mTLS leg to your node (edge mode for your node). Both keys are pinned at pairing; nothing else can deliver traffic. 3. The node connects **outbound** (embedded frp client) — no inbound port at home. ## Call budgets Every call counts against a budget of the account it is addressed to, sized by how many contacts that account may hold (HDTP §12). The budgets are not the node’s to decide: the **limits sidecar**, `hdtp-limitd`, holds their numbers and their counters and decides each call with hdtp-identity’s `hdtp-limits` crate, the decision the hosted cloud makes (SPEC §5.7). The node asks it over a unix socket, `limits_socket` (`HDTP_LIMITS_SOCKET`, default `/limits.sock`), on one connection it keeps open; nothing but the charge — the account, the caller’s root and tier, its address, the contact cap — crosses it. Its numbers are its configuration file, the rules document of hdtp-identity’s contract, shipped as `deploy/limitd/limits.json` (in the image at `/etc/hdtp-limitd/limits.json`): | Member | Budget | Keyed by | | ------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------- | | `contact_calls_per_second`, `contact_burst` | a contact | account, contact root | | `identity_capacity_per_second` | every contact together: `limit.contacts` × the contact rate, burst one second of it, at most this | account | | `guest_calls_per_hour` | a guest (a proven root that is not a contact) | account, root, address | | `guest_source_calls_per_hour` | an address alone (nothing proven) | account, address | | `stranger_calls_out_per_hour` | calls OUT to strangers (`request_contact`, `redeem_invite`, the answers to a request) | account | | `integration_calls_per_hour` | one integration’s tools, per contact | account, integration, contact | | `pending_in_cap` | requests waiting on the owner that strangers may write | account | | `guest_total_calls_per_hour` | every caller the open does not prove a contact, together (below) | account | **`guest_total_calls_per_hour` is checked before the envelope is opened** (the owner’s decision of 2026-09-29). Nothing unopened says who sent a sealed call, so the node asks the sidecar first, before it reads a key: while the account’s total holds a call, every sealed call goes on to be opened; once it holds none, only a call from a known address does — one that carried an active or pending_out contact’s call to the account in the last hour, which the sidecar remembers — and everything else is refused `rate_limited`, in the clear, with the total’s `retry_after`. Asking spends nothing. The total is spent after the open by every call that is opened and does not prove an active or pending_out contact: a guest, a blocked or superseded root, a root whose only row is the request it left, a small form naming a leaf nobody pinned, and an `envelope_invalid` or `certificate_renewed` the open found. A proven contact never spends it; a plaintext call opens nothing and never spends it. The known cost: during a flood, a contact calling from an address it has not used in the last hour is refused before the open, as a stranger is, with `retry_after`, until the total refills. The known addresses live in the sidecar’s memory beside the counters, an hour each and a bounded number an account (the oldest going first: `KNOWN_SOURCE_TTL_MS` and `KNOWN_SOURCES_CAP`, cmd/hdtp-limitd/src/lib.rs), so a sidecar restart forgets them too. A known address is an address: every caller arriving from it shares its standing. Behind a carrier that delivers every caller from one address of its own — `frp`, `ngrok` and `tailscale` from the node’s host, a terminate-mode ingress from its own, none of which names the client’s address — one contact’s call makes that one address known, and the check before the open lets every stranger through to be opened (and refused after it). The check does its work where each caller arrives with an address of its own: behind Envoy (below), or the `cloudflare` adapter. (`TestAStrangerFloodDrainsTheTotalThenIsRefusedBeforeTheOpenAndAKnownContactGetsThrough` shows a stranger at the contact’s known address opened and refused.) Calls out to a contact spend that contact’s rate and the account’s aggregate, in buckets of their own. A refusal is a `rate_limited` tool error carrying `retry_after` — the whole seconds until the bucket holds a call again — and an audited `rate_limited` row naming the bucket, not a dropped connection, so the caller’s agent can read it and back off. A call out that is refused never leaves the node. A request past `pending_in_cap` is answered `unavailable`: no wait empties a list only the owner can. **The shipped `identity_capacity_per_second` is measured, and it is below what 500 contacts ask for.** One node on an Apple M2 Max served sealed `send_message` from 500 contacts at up to 280 calls a second in every run, and broke between 300 and 450 a second from run to run (`TestMeasureAccountCapacity`, internal/node; the method and the numbers are in its file), and the shipped figure is the lowest knee with a margin. The figure is the node’s, and every identity on the node shares it. **Changing a number** is editing the file and restarting the sidecar, which reads it once and refuses one it cannot enforce (a rate of zero, a burst under one call, a contact bucket that takes longer than the hour an idle row is kept to refill), saying which member and why. The node needs no restart: `get_card` asks the sidecar for the numbers it advertises on every call. **When the sidecar is down, the node refuses.** Every sealed call, call out, request and integration call is answered `unavailable` until it answers again, which the node notices by itself. `/healthz` answers 503 and names the socket, so the container’s healthcheck fails; `hdtp-gateway doctor` prints `FAIL limits` with the reason, and the `serve` banner says `limits: NOT ANSWERING`. Start the sidecar (`hdtp-limitd -config `; the compose file runs it) and the node serves again with no restart. The counters live in the sidecar’s memory, one set for every node process on the host. A restart of the sidecar refills every bucket, which is the trade-off for not writing to a database on every call — the budget is there to blunt abuse, not to meter usage, and an attacker who can restart your sidecar has already won. Memory is bounded: a bucket idle for an hour is full whatever it budgets, so it is dropped (checked once a minute), and a caller cycling addresses or fingerprints cannot grow the table past who called in the last hour. The one budget setting that is the node’s is how many contacts each identity may hold, which sizes the aggregate: | Setting | Environment | Default | Meaning | | ---------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------- | | `limit.contacts` | `HDTP_LIMIT_CONTACTS` | 500 | contacts each identity may hold — active contacts plus the requests it sent — and the size of its call budget | It is an ordinary knob: the portal’s Settings page edits it under Security, the config file carries it as `limit_contacts`, and the environment pins it above both (SPEC §12.2), in which case the page shows it locked and says why. It is read per use, so a change takes effect with no restart. Empty, zero or unparseable restores 500 rather than removing the cap. The cap is enforced wherever a contact is added — approving a request, unblocking a contact, accepting somebody’s invite, sending a request, a peer redeeming an auto-accept invite, approving a contact at a new address, and an import — and nothing already held is removed when it is lowered. ## Connection bounds The public listener holds at most 1,024 connections open (SPEC §5.7); one more is closed before its TLS handshake, and the audit trail gets one `listener_full` row a minute while that continues, with how many there were. Requests have 10 s for their headers, 60 s in all, 75 s for the answer, and an idle connection is kept 120 s. Rate limits per address or for the whole node are not the node’s: put them where the traffic arrives — the edge, or a proxy in front of the node, such as the one below. ## Behind Envoy `deploy/envoy/` is the node behind a proxy of its own, the first of the two layers of its rate limits (the second is the sidecar above): `docker compose -f deploy/envoy/compose.yaml up -d` runs Envoy, the node and the limits sidecar, and only Envoy publishes a port. Before it: `make identity-proxy limitd-vendor` (the image build), a certificate for the node’s public name at `deploy/envoy/tls/cert.pem` and `key.pem`, and `HDTP_PUBLIC_URL`, that name, in the environment. What Envoy does (`deploy/envoy/envoy.yaml`, whose numbers are its own and nowhere else): * terminates the caller’s TLS with that certificate, which is what a caller now sees — WebPKI for the node’s name, as behind a terminating edge — and asks for the caller’s certificate chain, accepting any, since there is no authority above the person; * limits each source address per path — the MCP endpoints, the invite landing, everything else, each with a bucket of that address’s own — and answers 429 past it, before the node sees the request; and holds the listener’s connection cap and timeouts; * forwards the caller’s chain in `X-Forwarded-Client-Cert` and the address its socket saw in `X-HDTP-Client-Address`, replacing whatever the caller sent in either. The node reads those two headers only from Envoy’s address, `proxy_address` (`HDTP_PROXY_ADDRESS`, an IP; the compose file gives Envoy a fixed one on its network and the node that one). From anywhere else they are a caller’s own claim and prove nothing, and with no `proxy_address` they are never read. The node still opens every envelope: Envoy sees the MCP requests, never what a sealed one carries. `internal/integrationtest/envoy_test.go` holds the two files to what the node relies on, on every commit, and the harness’s S23 runs them with the image and floods them, with a control. ## The store, when it is large Nothing here needs setting. It is written down so that what the node does to its database is not a surprise, and so that the one knob that exists is findable. * **SQLite** (the default) is opened in WAL mode with `synchronous=FULL`, write transactions that take their lock at `BEGIN`, and a pool of four connections. Each was measured against a store of a million messages and the reasons are beside the code (`internal/core/store/sqlite.go`). `synchronous=FULL` is the one that costs speed on purpose: a commit here is a message a peer was told was delivered, and it is not given up to a power cut for a faster write. * **Postgres** (`store_engine: postgres`) uses pgx’s pool with its default size, the larger of four and the number of CPUs. The DSN is where it changes: append `pool_max_conns=16` (and `pool_min_conns`, `pool_max_conn_lifetime`) to `postgres_dsn`. * **Every hour** the node removes what has outlived its own window, whatever retention an account has set: idempotency records of sealed calls, which a node would otherwise keep one of for every call it ever took, owner sessions nobody came back to, and change-log rows older than a week. With a retention window set it also removes the messages, threads and media past it. * **Every statement the store can run is checked at build time** to have an index on any table that grows (`TestEveryQueryHasAPlan`, both engines). To see the numbers on your own hardware: ```plaintext HDTP_SCALE_DB=/tmp/hdtp-scale.db go test ./internal/core/store/ -run '^$' -bench '^BenchmarkScale' -benchtime 20x ``` The first run seeds the file — 10,000 contacts, a million messages, a million audit rows — and takes about a minute. `make scale` runs these benchmarks over a scratch file, and times an import of 40,000 threads against one of 10,000 (`HDTP_EXPORT_SCALE`), three rounds interleaved, failing if the best of three takes more than 6 times as long for 4 times the threads, in reading or in writing (linear is 4); the pre-push hook runs `make scale` last, after every other step. ## More than one process Several `serve` processes can share one store, each with its own `internal_bind` and `public_bind` behind whatever balances between them (SPEC §11.1): * **SQLite:** on one host, all with the same `data_dir`. Not across hosts, and not on a network filesystem: SQLite’s locking does not hold there. One limit of this profile: `Scrub`, the checkpoint that clears the write-ahead log after a leaf key is destroyed, cannot finish while another process is reading the file. It says so — a warning on an install or a signing request, an error on a retirement or a leave — and the next scrub that finishes clears the log; until then the destroyed key’s bytes may remain in it. Postgres has no scrub at all (SPEC §3.9). * **Postgres:** on any hosts, each with a `data_dir` of its own and the same `postgres_dsn`. Give every host the same master key in `HDTP_MASTER_KEY` (a host that generates a `keyring.key` of its own cannot open a key another sealed), and point `blob_dir` (`HDTP_BLOB_DIR`) at storage every host mounts, or media stored through one host is missing on the others. The first `serve` on an idle data dir migrates; the others check that the schema is the one they were built for and refuse to start if it is not. To migrate, stop every process on the data dir (on Postgres, every process) and start the new binary. `migrate`, `export`, `import` and the `audit` commands refuse to run while any `serve` holds the data dir. One process serves the admin socket and `serve` prints `admin: ... is served by another hdtp-gateway process` on the others; the setup URL a first run prints works on the portal of the process that printed it. The outbound retries and the hourly retention pass run on one process at a time: the one holding the work’s lease in the store, renewed every 10 s; a holder that stops lets it go at once, and one that crashes is replaced within 30 s. The call budgets are shared by every process on a host: they are the limits sidecar’s, and every process names the same `limits_socket`. A unix socket does not cross hosts, so on Postgres each host runs a sidecar of its own, and each host grants the whole budget. What each process still keeps to itself, and so what is not yet shared between them: * integrations: every process connects each one itself, so a stdio integration runs a child per process. An OAuth token is one for them all: the store holds it, and an expired one is refreshed by the one process holding that integration’s refresh lease while the others wait for it. ## Export and import ```plaintext hdtp-gateway export --config config.json --slug alina --out alina.zip hdtp-gateway import alina.zip --config config.json --slug alina # review: writes nothing hdtp-gateway import alina.zip --config config.json --slug alina --yes # writes it ``` Both are **offline** commands: stop the node first (they refuse while `hdtp.lock` is held). **An export is one identity’s contacts, chats and files, and nothing else** (SPEC §3.10, HDTP §9.2): one unencrypted zip of `manifest.json`, `contacts.csv`, `threads.csv`, `messages.jsonl` and `media/`, the same format the cloud and the `hdtp` CLI read and write. `export` says, before it writes, that the file is not encrypted: anyone who gets it can read the contact list and every conversation and file, though it holds no key and cannot be used to speak as anyone. The file is created `0600` and never replaces an existing one. A stranger’s request that was never accepted, and its conversation, stay behind. What is **not** in it, because it is the host’s and not the person’s: every key, saved settings, integration credentials, owners, passkeys, sessions, tokens, invites, the audit chain, the ledger of leaves, and a contact’s preset, trust flag and card. There is no flag that adds any of them. `import` checks the WHOLE file before it writes anything, and refuses it whole at the first fault, naming the member, row or line. Without `--yes` it shows what it would write and stops. Into a slug that is not here it creates the identity keyless — its root and nothing more; into the slug the file belongs to it merges, keeping every pin this host already holds. The rows go in under one transaction, the files after it. An undelivered outbound message arrives as `failed`: delivering it was the old host’s job, under the old host’s leaf. Every import ends the same way: a request for a new leaf, which the import mints itself — `move` for an identity that arrived, `renew` for one that was here — and prints with how to complete it (the portal’s `/identity//wallet` for the web wallet, or the request itself for the CLI wallet). Installing the leaf sends every imported contact this host’s handshake (`account announce` reports it). With no `public_url` set there is no address to ask for, and it names `account csr` instead. This is not a backup of the node, and the node has none: what a host accumulates beyond contacts and chats is rebuilt, not restored. After a lost machine: `import`, the setup wizard for a passkey, reconnect integrations, one certificate per identity. ## Recovery | Lost | Consequence | Do | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | the node, with an export per identity | identities are not served until re-certified; settings, integrations, passkeys and the audit history are not in an export | `import` each, `serve`, the setup wizard; then one `account csr` / `install-leaf` per identity. Contacts keep their pins | | `keyring.key` only | sealed leaf keys, saved settings and integration credentials are unreadable. **No identity is lost** — the root is in the wallet. If nothing on the node opens under the master key it was given, `serve` refuses to start and says why; if anything does, it starts and prints `NOT SERVED` for each account that does not | put the key back if it was kept anywhere, and nothing is lost. If it is gone for good: `export -slug` each identity, then `import` each into a fresh data directory — an export never needed the master key, because it never held anything sealed under it. For a single `NOT SERVED` account on a running node, a renewal alone does it: the install retires the key it cannot open and says so | | a leaf simply ran out (nobody renewed it) | the account stops being served within the hour and its key is destroyed; contacts keep their pins, and `doctor` warns before it happens | `account csr --slug me -purpose renew`, the wallet signs, `account install-leaf` | | a card or certificate on file that the identity core no longer reads (its reading got stricter with a release of the identity library: the identity core 0.4.2 refuses a character outside base64url in a card’s certificate that 0.4.1 skipped, and what intake accepted then is still on file) | the core refuses it where it reads it, and the node says so only then: a contact whose card does not read cannot be written to (`node.PeerOf` reads the card first and refuses — a refresh of that contact included), and a call from a contact whose pinned leaf does not parse, in either form, is this node’s unreadable state (`identity_state_unreadable` on the trail, `envelope_invalid` to the caller). The `serve` banner’s `store:` line and `hdtp-gateway check store` read every such field first and name each that does not read — table, row, field, reason — and change nothing; `check store` exits 1 on one, and runs beside a serving node | for a contact’s card: this node cannot ask for one; the contact’s own next `update_contact` writes one that reads, or remove the contact and re-add it from a new card of theirs. For a pin’s leaf: remove the contact and re-add it, since nothing it sends can be decided against that pin. Run `hdtp-gateway check store` before and after a binary that bumps the identity library | | a contact in a state neither a pin nor a request has (the schema admits only `active`, `pending_in`, `pending_out` and `blocked` — both engines — so such a row is a hand-edited store’s or a later binary’s) | the node hands the identity core no pin for it, where the core would refuse the state as unreadable: a call from its holder is decided as a stranger’s — the small form `chain_required`, the chain form a guest’s — and no effect reaches the row. The `serve` banner’s `store:` line counts such rows and a `NO PIN` line names each by account, root and status; `hdtp-gateway check store` prints the same and exits 1 | remove the contact (`remove_contact` ends a relationship in any state) and re-add it from a new card of theirs | | one account’s leaf key (compromised) | the thief speaks as that host until the leaf expires or is outranked | `hdtp-gateway account csr --slug me -purpose renew`, have the wallet sign it, `account install-leaf`: the newer leaf outranks the stolen one with every contact it reaches (HDTP §14.3) | | nothing: the person moved an identity to another host | this node goes on serving it, with its key, until told | `hdtp-gateway account leave -slug me -yes` once the new host has told the contacts (without `-yes` it shows what it would erase): every record and leaf key of the identity erased, its address reserved until its last leaf expires (SPEC.md §3.11) | | the wallet’s root | the identity itself; this node cannot help | the wallet’s own recovery, if it has one (HDTP §9, §14.5) | | the audit chain shows a break | someone altered history | `audit verify` names the first bad row; treat the store as untrusted from there | No telemetry leaves the node, ever; the audit chain is yours alone. # The owner MCP server > The node's owner MCP: 33 tools a node's owner gives their own agent, read from the node's source. The node serves its owner MCP at `/owner/mcp` (`internal/cli/compose.go`). Every call carries a bearer token minted over the admin socket — see [Add someone who invited you](/gateway/how-to/add-someone-who-invited-you/). What follows is every tool and resource the owner-MCP package registers: 33 tools, 3 resources and 1 resource template. Names and descriptions are read from the source. The argument schema of each tool is derived by the MCP SDK from its handler, so a running node’s `tools/list` is where to read it. ## Tools | Tool | What it does | Registered when | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `add_contact` | Reach out to a peer: redeem their invite link, or request contact with a card they gave you out of band (SPEC §9). Lands `pending_out` until they accept, or `active` immediately if their invite auto-accepts. | `e.AddContact != nil` | | `answer_request` | Answer one pending agent-answered request; the node relays to the waiting caller | | | `approve_address` | Re-pin a contact at the new address it is waiting at, as `auto` would have; the address it left is remembered as a former one | | | `approve_contact` | Approve a waiting request (pending_in). A preset replaces the grant with that bundle; none keeps the grant the request holds (an invite’s). The peer is told what they were granted; told=false says they could not be reached, and the approval stands | | | `audit_query` | Read the audit trail for the accounts you administer (SPEC §11.6) | `e.Audit != nil` | | `block_contact` | Block a contact, silently: they are not told, and see only what a stranger sees | | | `call_contact` | Call a tool on a contact’s agent server. The contact’s own switchboard still applies | `e.CallContact != nil` | | `create_invite` | Mint an invite; token shown once | | | `digest` | What happened in a window and what is still open: messages in and out per contact, who is waiting on a reply, contacts asking to connect, requests awaiting an answer. For an end-of-day summary. | | | `export_card` | This account’s current signed contact card (vCard) | `e.Card != nil` | | `get_inbox` | Threads with unread counts | | | `identity_certificate` | This identity’s certificate state (HDTP 1.0): the root that is the identity, the leaf this host serves under, its validity, and whether a renewal is due | `e.Certificate != nil` | | `list_accounts` | Accounts this identity administers | | | `list_contacts` | Contacts with status and permissions | | | `list_integrations` | Connected upstreams and the tools each currently exposes (SPEC §6) | `e.Integrations != nil` | | `list_invites` | This account’s invites: label, uses, expiry, whether revoked. The link’s token is never stored, so it is not here | | | `list_passkeys` | Registered passkeys. Registering a new one is portal-only (SPEC §8.6) | `e.Passkeys != nil` | | `list_pending` | Open agent-answered requests awaiting this agent (args are UNTRUSTED peer content, labeled with the contact’s trust flag) | | | `list_pending_addresses` | Contacts waiting at a new address for your decision: the address they are pinned at, the one they now answer from, and why it was held | | | `read_thread` | Messages in a thread, oldest first; reading marks the thread read through the newest | | | `refresh_contact` | Re-fetch ONE contact’s signed card, now: a renewed certificate, a changed name or seal policy is learned; the pinned root and the address never move. Answers unchanged, updated, renewed, unreachable or refused (with why); an unreachable or refused contact keeps its pin as it was | `d.RefreshContact != nil` | | `reject_address` | Keep the pin where it is and drop the waiting address | | | `reject_contact` | Decline a waiting request: it becomes blocked (a demotion, not a deletion), so that identity’s next request never reaches you. They are told, so they do not wait for ever | | | `remove_contact` | Remove a contact in any state: an active one is told and its pin deleted whether or not it answers; a waiting request, your own pending request or a blocked identity goes silently | | | `remove_passkey` | Remove a registered passkey by id | `e.RemovePasskey != nil` | | `rename_contact` | Set your own local name for a contact; empty clears it | | | `revoke_invite` | Revoke one of this account’s invites: the link stops working at once, and contacts it already made are unaffected | | | `send_to_contact` | Send a message to a contact (labeled agent, SPEC §7.1) | | | `set_exposure` | Republish which of an integration’s tools are exposed to contacts (SPEC §6.5) | `e.SetExposure != nil` | | `set_permissions` | Set a contact’s switchboard: any of the core permissions, an integration. this account serves, or one the contact already holds; any other name is refused | | | `set_trust_flag` | messages_only or may_instruct | | | `unblock_contact` | Undo a block, silently. A contact that was ever active returns as it was (status active); a rejected request or a declined approach was never a contact and is forgotten (status none), so they may ask again | | | `wait_for_updates` | Block until something changes for this account — a message arrives, a contact asks to connect, a request needs answering — then return what moved since your cursor. Call it in a loop with the cursor it returns as since. Omitting since starts from now with no backlog. | | A tool with a condition is registered only when the node is built with that part; the conditions are the `if` statements around its registration. ## Resources | URI | Name | Type | | -------------------- | ------------------------------- | ------------------ | | `hdtp://requests` | contact requests | `application/json` | | `hdtp://inbox` | inbox | `application/json` | | `hdtp://pending` | pending agent-answered requests | `application/json` | | `hdtp://thread/{id}` | thread | `application/json` | # Quickstart > Five minutes from nothing to a working node. Five minutes from nothing to a working node. You need Docker with Compose, Go, Rust (cargo), and SSH access to the private identity module (see CONTRIBUTING.md). ```plaintext make identity-proxy # fetch the identity module on this machine, for the image build make limitd-vendor # and the limits sidecar's crates, which include the identity's hdtp-limits docker compose up -d # the node and its limits sidecar (SPEC §5.7) docker compose logs hdtp-gateway | grep -A2 "setup" ``` The log prints your portal URL and a **one-time setup token**. Open it — the wizard registers your first **passkey**, which is the node’s only login, on every bind including loopback. Register a second on another device: there is deliberately no online recovery path. If you lose them all, recovery needs shell access on the host — `hdtp-gateway passkey reset-wizard` mints a one-time link that re-opens registration. Then create the identity people will reach, and have your wallet certify this node for it (see [Your wallet](/gateway/how-to/your-wallet/)): ```plaintext docker compose exec hdtp-gateway hdtp-gateway account create --slug me --name "Your Name" docker compose exec -T hdtp-gateway hdtp-gateway account csr --slug me > me.csr hdtp id create --name "Your Name" --vault me.hdtp-vault.json hdtp id issue --vault me.hdtp-vault.json --csr me.csr --chain-out chain.pem docker compose cp chain.pem hdtp-gateway:/tmp/chain.pem docker compose exec hdtp-gateway hdtp-gateway account install-leaf --slug me --chain /tmp/chain.pem ``` Open *Card* in the portal and download `me.vcf`. That is what you hand to people. Until the chain is installed there is no card: the page says the account has no certificate yet. > This quickstart is not prose someone hopes still works. An automated scenario builds these images, drives the portal through a real Chrome, registers a passkey with a virtual authenticator, and pairs a contact. Running it as written is how three bugs in it were found. # Audit, telemetry and trade-offs > Append-only and hash-chained, recording refusals as loudly as successes. ## Every call is audited Append-only and hash-chained, recording refusals as loudly as successes. “What did my node actually do” has an answer, and it is not one that can be quietly edited. ![The audit trail: sequence, actor, action, outcome](/images/gateway/audit.png) ## No telemetry, ever The node contacts nothing except what you configured. No phone-home, no crash reporter, no analytics, and no build flag that turns one on. *** ## Honest trade-offs Written down because they do not disappear by going unmentioned: * **Tunnels and edges see metadata** — which node is called, size, timing. Sealed content stays ciphertext to them; the fact of a conversation does not. * **No forward secrecy at the envelope layer.** A compromised leaf key opens envelopes an attacker kept from while it was current — bounded by the leaf’s life, at most 398 days, and shorter if you renew. * **Lose your root, lose that identity.** The root lives in your wallet and nowhere else — no host holds a copy and nobody can mint you another. Losing the host’s *leaf* key is different and recoverable: your wallet issues a new one. * **Card trust is channel trust.** A card handed over a hostile channel is a hostile card. The fingerprint is the thing to check.