Download OpenAPI specification:
Every error response is one envelope:
{ "code": "not_found", "status": "link_not_found", "error": null }
code is the category (bad_request, unauthorized, payment_required, forbidden,
not_found, conflict, gone, rate_limited, payload_too_large, internal,
unsupported) or a domain code with its own typed details, such as
storage_quota_exceeded. status names the exact condition. error is an optional
diagnostic text and details an optional typed payload. The complete list of codes,
statuses and details shapes is the x-error-catalog on the ErrorResponse schema.
Handle an unknown code by its HTTP status.
Requests are limited per client IP before authentication and per actor after it. A
rejected request answers 429 with status rate_limit_exceeded and the headers
Retry-After, RateLimit-Remaining and RateLimit-Reset.
A state-changing request (POST, PUT, PATCH, DELETE) authorized by the session cookie
must carry the X-CSRF-Token header with the value of the csrf_token cookie. The
server sets both cookies together, starting with POST /auth/agents. A missing or
invalid token answers 403 with status csrf_required or csrf_invalid. Bearer
requests need no token. Operations marked x-csrf-exempt (register, token refresh,
login start, challenge verify, token revoke, the SSO exchanges) never require it, so a
stale cookie cannot block a re-login.
State changes are also pushed over one server-to-client WebSocket; REST stays the
source of truth. Connect with GET /ws/connect?ticket=… (browser, ticket from
POST /auth/agents) or GET /ws/action with a Bearer token (native). Subscriptions
are managed over REST (POST /events/subscriptions), belong to the caller rather than
the socket, and survive reconnects. Topics are user:<user_id>,
workspace:<workspace_id>, link:<url_token> and link:* (every link the caller
owns); the caller's own agent:<agent_id> inbox is always delivered.
Every frame is one WSMessage variant: a flat object whose dotted type selects the
shape, with id, topic and timestamp beside the variant's own fields. Frames are
stored server-side: reconnect with last_seen_id to receive what was missed, then the
live stream continues. Past the retention window re-read the resource over REST. Each
subscriber receives a frame once, even when it is published to several topics.
POST /links/{url_token}/acl/grant repeated with the same body answers
status: unchanged; a different body for the same subject answers 409
already_granted_different. Billing mutations take an Idempotency-Key header: the
same key with the same request replays the original operation, the same key with a
different request answers 409. Deciding a vault join request that is already decided
returns its original outcome.
Cloud access is per-file presigned URLs; there is no credential to refresh. To renew
an expired URL or add files, call POST /links/{url_token}/{direction}/presign. A
large file uses the multipart flow: upload/multipart/begin, upload/multipart/parts
(repeatable), then upload/multipart/complete or upload/multipart/abort;
list-uploads and list-parts resume an interrupted upload.
POST /auth/sessions with an email always answers a challenge listing the account's
sign-in methods, in the same shape for known and unknown addresses. Verify any one of
them with POST /auth/challenges/{token}; with two-step verification on, a password or
email-code sign-in is followed by any one other factor from the list the response
carries, while a passkey completes the login on its own. A code by email or SMS is sent
on request (POST /auth/challenges/{token}/code), not by the call that lists it. A sensitive
operation called without a fresh factor answers 403 challenge_required with a
challenge bound to that call; verify it and retry with the token in the
X-Auth-Challenge header.
POST /auth/sessions/revoke-all ends the user's other sessions at once, sparing the
calling one; when the calling device is in the vault's trust circle, every other
device leaves the circle in the same call. Account suspension, vault recovery and
vault reset end them all. The
explaining frame (session.revoked_all, auth.user_suspended) is delivered before
the socket closes and is replayed on reconnect. Treat an unknown reason or category as the generic case.
Device registration, login, sessions and tokens. POST /auth/agents registers the
device and returns an access token, a refresh token and a WebSocket ticket. Login
starts with POST /auth/sessions and completes through POST /auth/challenges/{token},
one factor per call; the resulting access token carries both the agent and the user.
Third-party access uses the OAuth authorization-code flow (/auth/authorize,
POST /auth/token). SSO with Google and Apple runs through /auth/sso/{provider} on
the web and POST /auth/sso/{provider}/token with a native ID token. Refresh tokens
rotate on every refresh. Factor types: password, email_code, webauthn, totp,
sms, device_push; two-step verification and recovery codes live under
/users/me/auth/.
Registers the calling device and issues its session. device_public_key is the device
identity and its trust-circle key: the same key refreshes the same agent, a different
key creates a new one.
Returns agent_id, name and a one-time WebSocket ticket valid for 30 seconds —
connect to GET /ws/connect?ticket=… at once. With response_mode=token the session
comes back as access_token and refresh_token, otherwise as cookies. name and
nearby_visibility are stored when sent and untouched when omitted; a new device
starts at everyone. Sign in with POST /auth/sessions.
| response_mode | string Default: "cookie" Enum: "cookie" "token" Example: response_mode=token Where the issued tokens go. |
| name | string <= 255 characters Device name shown in the interface. Auto-generated when empty. |
| client_build | string <= 64 characters Build version of the client application |
object Device metadata. | |
| device_public_key required | string <byte> <= 64 characters X25519 public key that identifies the device and serves as its trust-circle key. Raw key bytes in base64, exactly 32 bytes after decoding; a register without it is refused. |
| nearby_visibility | string (NearbyVisibility) Enum: "everyone" "nobody" Who may discover this device in nearby lists.
An agent is identified by device plus network, so a device registering from a different
network starts at |
| instance_id | string^[-a-zA-Z0-9_]{1,64}$ Opaque id of the client runtime this register runs in; a web client makes a fresh one per tab. A link bound to it is served only while a socket of that instance is live. Native clients omit it. |
{- "name": "My MacBook",
- "client_build": "1.2.3",
- "device_info": {
- "product_id": "com.shareninja.desktop",
- "device_os": "macos",
- "device_os_version": "14.5.0",
- "device_locale": "en-US",
- "device_serial": "C02X1234ABCD",
- "device_model": "MacBookPro18,1",
- "device_form_factor": "laptop",
- "device_name": "Johns-MacBook.local",
- "release_channel": "production"
}, - "device_public_key": "q8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA=",
- "nearby_visibility": "everyone",
- "instance_id": "tab-9f2c4b1a"
}{- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "access_token": "string",
- "refresh_token": "string",
- "token_type": "Bearer",
- "expires_in": 900,
- "name": "Crimson Falcon",
- "ticket": "x7KPqRsT2uVwXyZ9aBcDeFgHiJkLmNoP1234567890AB",
- "ticket_expires_in": 30,
- "encryption_public_key": "string",
- "signing_public_key": "string",
- "encrypted_identity_key": "string",
- "vault_status": "not_initialized"
}Exchanges a refresh token, or an authorization code from the consent flow, for a new
access token. The grant type selects the body shape; see TokenRequest.
A refresh token is rotated on every use: the response carries a new one and the token
sent stops working. Replaying an already-rotated token past the grace window answers
401 refresh_token_reused and revokes the session, so the client must authenticate
again.
Device tokens are issued by POST /auth/agents and user tokens by
POST /auth/challenges/{token}; this endpoint only renews them.
| response_mode | string Default: "cookie" Enum: "cookie" "token" Example: response_mode=token Where the issued tokens go. |
| grant_type required | string Must be "refresh_token" |
| refresh_token required | string The refresh token to exchange |
{- "grant_type": "refresh_token",
- "refresh_token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
}{- "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidXNyXzEyMzQ1Njc4IiwibW9kZSI6InVzZXIiLCJleHAiOjE3MDI2NTYwMDB9.signature",
- "token_type": "Bearer",
- "expires_in": 900,
- "refresh_token": "b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1",
- "scope": "user"
}Revokes a refresh token per RFC 7009, ending the session it belongs to. Answers 204 whether or not the token was valid, so the response cannot be used to probe a token.
| token required | string The refresh token to revoke |
| token_type_hint | string Value: "refresh_token" |
{- "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6",
- "token_type_hint": "refresh_token"
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Revokes the refresh token taken from the body, or from the refresh_token cookie
when the body carries none, and clears the session cookies.
| refresh_token | string Refresh token to revoke. The cookie is used when it is absent. |
{- "refresh_token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Returns the details of an authorization request so the client can render the consent
screen. There is no client registration: code_challenge is required, and the
matching verifier is checked when the code is exchanged.
| redirect_uri required | string Example: redirect_uri=https://app.example.com/callback Where to redirect after consent |
| code_challenge required | string [ 43 .. 44 ] characters ^[A-Za-z0-9_-]{43}=?$ Example: code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM PKCE code challenge — S256, base64url of the SHA-256 hash, 43 characters, a trailing |
| code_challenge_method | string Default: "S256" Value: "S256" Example: code_challenge_method=S256 |
| scope | string Example: scope=user Requested scopes, space-separated |
| state | string Example: state=xyz123 Opaque value returned unchanged in the redirect |
| agent_id | string Example: agent_id=agt_9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f The requesting client's own device. When set, the code and the session its
exchange mints belong to this device rather than to the one that approves the
consent. Echoed back so the consent page can pass it to the POST, together with
the device's registered card ( |
{- "scope": "user",
- "scopes": [
- "user"
], - "state": "xyz123",
- "agent_id": "agt_9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
- "display_name": "My MacBook",
- "product_id": "com.shareninja.desktop",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}Records the user's consent decision and returns the redirect URL carrying the
authorization code, which POST /auth/token exchanges together with the PKCE
verifier. Requires a signed-in user.
The code belongs to the device named by agent_id when it is given — the requesting
client's own device, in the same app as the approving session — and to the approving
device otherwise; the exchanged session is minted for that device.
| redirect_uri required | string |
| code_challenge required | string [ 43 .. 44 ] characters ^[A-Za-z0-9_-]{43}=?$ PKCE code challenge — S256, base64url of the SHA-256 hash, 43 characters, a trailing |
| code_challenge_method | string Default: "S256" Value: "S256" |
| scope | string |
| state | string |
| approve required | boolean true approves the request, false denies it |
| agent_id | string The requesting client's own device, echoed from the GET. When set, the code and the exchanged session belong to it rather than to the approving device. |
{- "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
- "code_challenge_method": "S256",
- "scope": "user",
- "state": "xyz123",
- "approve": true,
- "agent_id": "agt_9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f"
}{
}Deprecated: use GET /users/me/devices, which returns the same sessions attached to
their device cards.
Returns the authenticated user's sessions that are not revoked. Each carries its id for selective revocation, the address and user agent it was created with, when it was created and last used, and whether it is the calling session.
{- "items": [
- {
- "id": "018f7b2e-3c4d-7a1b-9e6f-2a5c8d1e4b7a",
- "ip_address": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
- "created_at": "2025-01-15T10:30:00Z",
- "last_used_at": "2025-01-17T14:22:00Z",
- "is_current": true,
- "product_id": "com.anjuta.desktop",
- "display_name": "My MacBook",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}
]
}Starts authentication for an email address. An address with no account is signed up by this very call; there is no separate registration step.
The response always carries a challenge listing the account's sign-in methods, in a
shape identical for a known and an unknown address, so registered addresses cannot
be enumerated. No session is issued here, and no email code is sent unless
email_code is the only method: otherwise request it with
POST /auth/challenges/{token}/code once the user picks it. An account that removed
its email_code factor lists no such entry; a lost password is reset through
POST /auth/recovery.
Verify one method with POST /auth/challenges/{token}; the response completes the
login or lists the second step.
| email required | string <email> <= 320 characters Account email address |
| remember_me | boolean Default: false When true, the issued session gets the deployment's extended lifetime. |
| accept_terms | boolean Deprecated Accepted and ignored. Signup is implicit: an unknown email is signed up by this very call. |
| display_name | string or null <= 255 characters Display name for a new account. Derived from the email when absent, and ignored for an account that already exists. |
| invite_token | string or null [ 6 .. 128 ] characters Invite token from an |
| time_zone | string <= 64 characters IANA name of the time zone the client is in, such as |
Send the email address to start the flow
{- "email": "user@example.com"
}The user picks the password or the email code; the code is only mailed
if requested with POST /auth/challenges/{token}/code
{- "challenge": {
- "token": "ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5",
- "expires_at": "2025-05-20T10:30:00Z",
- "sign_in_methods": [
- {
- "id": "pwd_abc123def456xyz7",
- "type": "password"
}, - {
- "id": "email_code",
- "type": "email_code"
}
]
}
}Revokes the caller's own session together with its refresh tokens, and clears the session cookies.
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Revokes the named session and its refresh tokens, signing out the device that holds it.
| session_id required | string Example: 018f7b2e-3c4d-7a1b-9e6f-2a5c8d1e4b7a Identifier of the session to revoke |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Revokes every other session of the current user: every other signed-in device is signed out, while the calling session stays signed in and keeps its credential.
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Always answers 403 challenge_required: a challenge is never minted out of band.
To confirm a sensitive operation, call that operation without confirmation. Its 403
challenge_required refusal carries a challenge bound to that exact call; verify it
with POST /auth/challenges/{token} and retry the operation with the token in the
X-Auth-Challenge header.
{- "token": "ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5",
- "expires_at": "2025-05-20T10:35:00Z",
- "sign_in_methods": [
- {
- "id": "pwd_abc123def456xyz7",
- "type": "password"
}, - {
- "id": "email_code",
- "type": "email_code"
}
]
}Verifies one entry of the challenge named by token: a sign-in method first, then —
when the response lists second_factors — one of those. The call needs a registered
device: its access credential names the device the session is minted on.
For a login or signup, the step that completes it issues the session. With response_mode=token
it comes back as access_token and refresh_token, otherwise it is set as cookies.
For a step-up, complete: true means the path token is now spendable: retry the
operation with it in the X-Auth-Challenge header.
A wrong credential, or a factor_id the account does not have, answers 401
invalid_credentials and counts against the attempt limit.
A recovery with two-step verification on whose second step nothing is left to
answer is refused with 409 no_second_factor.
| token required | string Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token to verify |
| response_mode | string Default: "cookie" Enum: "cookie" "token" Example: response_mode=token Where the issued tokens go. |
| factor_id required | string The |
required | PasswordCredentials (object) or CodeCredentials (object) or WebAuthnCredentials (object) or RecoveryCodeCredentials (object) Credentials for the entry named by |
{- "factor_id": "totp_abc123def456xyz7",
- "credentials": {
- "code": "123456"
}
}{- "complete": true,
- "access_token": "abc123xyz789...",
- "user": {
- "user_id": "usr_new1234567890123456",
- "email": "newuser@example.com",
- "display_name": "newuser",
- "avatar_url": null,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z",
- "two_step_enabled": false,
- "workspaces": [ ],
- "effective_permissions": [ ]
}
}Sends the code for one code-delivering entry of the challenge named by token — the
email_code entry by email, an sms entry by text — a fresh one on every call, so
it also resends. Each send draws on the address's code budget. An entry the
challenge does not list at its current step, or one that delivers no code, answers
factor_not_allowed.
| token required | string Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token to send the code for |
| factor_id required | string The |
{- "factor_id": "email_code"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Starts a password recovery for an email address and mails its code at once. The
response is a challenge whose only entry is email_code, in a shape identical for a
known and an unknown address; an unknown one gets no mail. Verify the code with
POST /auth/challenges/{token}: with two-step verification on, the second step
follows as for a login, without the password: any other confirmed factor or a
recovery code. With nothing left to answer it, verify refuses the recovery
(no_second_factor). A completed recovery issues no session; it answers
complete with the token that POST /auth/recovery/{token}/password spends.
| email required | string <email> <= 320 characters Account email address |
{- "email": "user@example.com"
}{- "token": "ch_rec123abc456def789",
- "expires_at": "2025-05-20T10:35:00Z",
- "sign_in_methods": [
- {
- "id": "email_code",
- "type": "email_code"
}
]
}Sets the account's password from a completed recovery challenge, replacing the
existing one, and ends every session of the account. The token is spent by this
call. An unknown, unverified or already spent token answers
invalid_challenge_token; one past its time, challenge_expired. A completed
recovery opens no session: the client signs in with the new password.
| token required | string Example: ch_rec123abc456def789 The recovery challenge token, verified |
| password required | string The new password, in the same form |
{- "password": "a]3N$k9Lm#pQ2wX"
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Returns the SSO providers enabled for this deployment, each with the label to show on its sign-in button.
{- "providers": [
- {
- "name": "google",
- "label": "Sign in with Google"
}, - {
- "name": "apple",
- "label": "Sign in with Apple"
}
]
}Returns the provider's authorization URL for the client to open, and sets the state
cookie the callback checks. The provider then redirects to
/auth/sso/{provider}/callback with an authorization code.
The call must carry the calling device's access credential — the SSO login binds to that device — and answers 401 without one. An expired access token is still accepted here, because the round trip through the provider can outlast its 15-minute lifetime.
| provider required | string Enum: "google" "apple" SSO provider name |
| redirect_uri | string Where to return once SSO completes: a path on this origin, a native app scheme, or an allow-listed URL. |
{- "auth_url": "string"
}Completes the provider's redirect: checks state against the state cookie, exchanges
the code with the provider, and establishes the session on the device the flow started
from. Session cookies are set either way; with response_mode=token the profile is
returned as JSON instead of the redirect.
| provider required | string Enum: "google" "apple" |
| code required | string Authorization code from the provider |
| state required | string State value, checked against the state cookie |
| response_mode | string Default: "cookie" Enum: "cookie" "token" Example: response_mode=token Where the issued tokens go. |
{- "user": {
- "user_id": "usr_6f1a9c3e-8b2d-4f7a-9c1e-5d3b7a2f8e4c",
- "email": "pat@example.com",
- "display_name": "Pat Manager",
- "created_at": "2024-06-02T14:12:09Z",
- "two_step_enabled": false,
- "updated_at": "2025-03-18T09:45:21Z",
- "impersonating": {
- "target_user_id": "usr_a4e7c2d9-3b1f-4e6a-8d5c-2f9b7e1a6c3d",
- "started_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z"
}, - "workspaces": [
- {
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "description": "Main workspace for the product group",
- "member_count": 5,
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "inherit_parent_permissions": true,
- "joined_at": "2024-08-11T08:05:44Z",
- "billing_root_workspace_id": "ws_a1b2c3d4e5f6g7h8"
}
], - "avatar_source": "uploaded",
- "avatar_updated_at": "2025-12-10T18:30:00Z",
- "preferred_region": "wasabi-eu",
- "identity_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA=",
- "language": "de",
- "time_zone": "Europe/Berlin"
}, - "is_new_user": false
}Completes the flow for a provider that posts the callback as form data instead of redirecting with query parameters, which is how Apple Sign-In returns. Otherwise it behaves like the GET callback.
| provider required | string Value: "apple" |
| code | string Authorization code |
| state | string State value, checked against the state cookie |
| id_token | string ID token, which Apple supplies directly |
| user | string JSON-encoded user info, sent on the first sign-in only |
{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Exchanges an ID token that a native app obtained from the Google or Apple sign-in SDK
for a session, creating the account when the identity is new. The session is set as
cookies and the body returns the profile with is_new_user. The call must carry the
calling device's access credential; the session binds to that device.
An identity linked to no account whose verified address already belongs to one is
refused with 409 email_already_in_use.
| provider required | string Enum: "google" "apple" |
| response_mode | string Default: "cookie" Enum: "cookie" "token" Example: response_mode=token Where the issued tokens go. |
| id_token required | string ID token from the SSO provider |
| authorization_code | string Authorization code the provider SDK returned next to the id_token. The server redeems it for the provider's refresh token, which it revokes when the account is deleted. Required for Sign in with Apple: without it the identity cannot be revoked at Apple on deletion. |
| display_name | string <= 255 characters Display name for a new account |
| accept_terms | boolean Deprecated Default: false Accepted and ignored. Signup is implicit for an unknown identity. |
{- "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ...",
- "display_name": "John Doe",
- "accept_terms": true
}{- "user": {
- "user_id": "usr_6f1a9c3e-8b2d-4f7a-9c1e-5d3b7a2f8e4c",
- "email": "pat@example.com",
- "display_name": "Pat Manager",
- "created_at": "2024-06-02T14:12:09Z",
- "two_step_enabled": false,
- "updated_at": "2025-03-18T09:45:21Z",
- "impersonating": {
- "target_user_id": "usr_a4e7c2d9-3b1f-4e6a-8d5c-2f9b7e1a6c3d",
- "started_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z"
}, - "workspaces": [
- {
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "description": "Main workspace for the product group",
- "member_count": 5,
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "inherit_parent_permissions": true,
- "joined_at": "2024-08-11T08:05:44Z",
- "billing_root_workspace_id": "ws_a1b2c3d4e5f6g7h8"
}
], - "avatar_source": "uploaded",
- "avatar_updated_at": "2025-12-10T18:30:00Z",
- "preferred_region": "wasabi-eu",
- "identity_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA=",
- "language": "de",
- "time_zone": "Europe/Berlin"
}, - "is_new_user": false
}Returns every authentication factor on the caller's account with its type and
confirmation state, alongside available_types: the types the account may enroll
now — what this deployment accepts, and email_code only while the account has
none. A factor takes part in login once confirmed.
{- "items": [
- {
- "id": "totp_abc123def456xyz7",
- "type": "totp",
- "is_confirmed": true,
- "created_at": "2025-04-11T18:43:02Z",
- "last_used_at": "2025-05-02T09:21:14Z"
}, - {
- "id": "totp_987zyxwvu654tsr3",
- "type": "totp",
- "is_confirmed": false,
- "created_at": "2025-05-20T08:00:00Z"
}
], - "available_types": [
- "password",
- "totp",
- "email_code",
- "webauthn"
]
}Creates a factor on the caller's account and returns what is needed to finish setup.
A verified challenge in X-Auth-Challenge is required, unless this session confirmed
another factor change within the deployment's step-up window.
The factor takes no part in login until it is confirmed. A password arrives
confirmed, creating it being the proof, replaces the account's existing one and
signs every other session of the account out — the one that set it stays;
email_code arrives confirmed too, the address being the account's own. TOTP
returns the shared secret and provisioning URI once, here; sms sends a code;
webauthn returns registration options. Confirm those with
POST /users/me/auth/factors/{factor_id}/confirm.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| type required | string Enum: "totp" "email_code" "sms" "password" "webauthn" Factor type to create |
| phone_number | string Phone number in E.164 format. Required when |
| password | string The password, 8 to 128 characters; required when |
TOTPCreateMetadata (object) or EmailCodeCreateMetadata (object) Type-specific parameters, for |
{- "type": "totp",
- "metadata": {
- "issuer": "Example",
- "digits": 6,
- "period": 30
}
}{- "factor": {
- "id": "totp_abc123def456xyz7",
- "type": "totp",
- "is_confirmed": false,
- "created_at": "2025-05-20T08:00:00Z",
- "capabilities": {
- "can_delete": true,
- "requires_challenge_for_delete": true
}
}, - "enrollment": {
- "provisioning_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=6&period=30",
- "shared_secret": "JBSWY3DPEHPK3PXP"
}
}Returns one factor of the caller's own account: its type, confirmation state and
what may be changed about it. A factor id belonging to
another account reads as factor_not_found.
| factor_id required | string Example: totp_abc123def456xyz7 Public identifier of the factor |
{- "id": "totp_a1b2c3d4e5f6g7h8",
- "type": "totp",
- "is_confirmed": true,
- "capabilities": {
- "can_delete": true,
- "requires_challenge_for_delete": true
}, - "created_at": "2025-05-20T08:00:00Z",
- "last_used_at": "2025-05-21T10:20:00Z",
- "metadata": { }
}Deletes one of the caller's own factors. A verified challenge in X-Auth-Challenge
is required; without one the call answers 403 challenge_required with a challenge
bound to this deletion, unless this session confirmed another factor change within
the deployment's step-up window with a factor other than this one.
The account's last sign-in method — password, passkey or email code — will not go
(cannot_delete_last_sign_in_method). A deletion that leaves the account with a
single confirmed factor while two-step verification is on switches it off, voids
the recovery codes and says so in the response.
| factor_id required | string Example: totp_abc123def456xyz7 Public identifier of the factor to remove |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
{- "two_step_disabled": false
}Proves a newly enrolled factor works and marks it confirmed. Send the code from the
authenticator app, the email or the SMS as {"code": "123456"}, or the browser's
registration result as webauthn_response. A password factor is confirmed when it
is created, so it does not use this call.
A confirmed factor takes part in login from then on, at the step its type decides. A
wrong credential answers invalid_code, and a factor that is already confirmed
answers factor_already_confirmed.
| factor_id required | string Example: totp_abc123def456xyz7 Public identifier of the factor to confirm |
The proof the factor works: a code for totp, email_code and sms, the
registration result for webauthn.
| code | string [ 6 .. 8 ] characters 6 to 8 digit verification code, for |
object (WebAuthnAttestationResponse) The |
{- "code": "123456"
}{- "factor": {
- "id": "totp_a1b2c3d4e5f6g7h8",
- "type": "totp",
- "is_confirmed": true,
- "capabilities": {
- "can_delete": true,
- "requires_challenge_for_delete": true
}, - "created_at": "2025-05-20T08:00:00Z",
- "last_used_at": "2025-05-21T10:20:00Z",
- "metadata": { }
}, - "enrollment": {
- "provisioning_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=6&period=30",
- "shared_secret": "JBSWY3DPEHPK3PXP"
}, - "access_token": "string"
}Turns two-step verification on for the caller's account and returns its recovery codes, shown this once. From now on a sign-in by password or email code is followed by any other confirmed factor of the account; a passkey or SSO sign-in completes on its own.
Needs at least two confirmed factors (second_factor_required) and a verified
challenge in X-Auth-Challenge, unless
this session confirmed another factor change within the deployment's step-up window. Already
on answers two_step_already_enabled.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "codes": [
- "k7m2-p9qw-x4tz",
- "b3rd-6fhn-vq8s",
- "w9jc-2kpe-h5ym"
], - "generated_at": "2025-05-20T08:00:00Z"
}Turns two-step verification off and voids the recovery codes; the factors stay
enrolled. A verified challenge in X-Auth-Challenge is required,
unless this session confirmed another factor change within the deployment's step-up window.
Already off answers two_step_not_enabled.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Issues a fresh set of recovery codes, shown this once, and voids the previous set.
Needs two-step verification on (two_step_not_enabled) and a verified challenge in
X-Auth-Challenge, unless this session confirmed another factor change within the
last five minutes.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "codes": [
- "k7m2-p9qw-x4tz",
- "b3rd-6fhn-vq8s",
- "w9jc-2kpe-h5ym"
], - "generated_at": "2025-05-20T08:00:00Z"
}Updates the caller's own profile and returns it in full. display_name renames the
account. avatar carries a base64 data URL to upload a new image, an empty string
to drop the uploaded one, and is left out to keep the current one. A request that
changes neither is rejected.
The email address is changed through POST /users/me/email/change, not here. An
image larger than 5 MB answers 413 avatar_too_large; an unsupported format or one
outside the accepted pixel range answers 400.
| display_name | string [ 1 .. 255 ] characters |
| avatar | string or null The image as a base64 data URL, in JPEG, PNG, WebP or GIF, at most 5 MB decoded
and 64×64 to 4096×4096 pixels. An empty string deletes it; omitted or |
| language | string [ 1 .. 64 ] characters The user's language as a tag, such as |
| time_zone | string [ 1 .. 64 ] characters IANA name of the user's time zone, such as |
{- "display_name": "Patricia Manager"
}{- "user_id": "usr_pat1234567890123456",
- "email": "pat@example.com",
- "display_name": "Patricia Manager",
- "avatar_source": "uploaded",
- "avatar_updated_at": "2025-04-01T09:02:11Z",
- "created_at": "2024-06-02T14:12:09Z",
- "updated_at": "2025-04-01T09:02:11Z",
- "two_step_enabled": false,
- "workspaces": [
- {
- "workspace_id": "ws_coreworkspace1",
- "name": "Core Workspace",
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "joined_at": "2024-06-02T14:12:09Z"
}
]
}Deletes the caller's account together with the workspaces it owns and everything anchored to them, and ends every session on every device. Irreversible.
Billing settles first: every subscription must already be inactive at the provider
and no billing operation may be left pending, so scheduling a cancellation is not
enough. An account that has not settled answers 409 billing_cancellation_required;
a workspace the caller still owns and cannot take along answers 409
user_owns_workspaces, so hand it over before retrying.
An identity linked through Sign in with Apple is revoked at Apple before the account goes; if Apple refuses, the deletion fails and can be retried.
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Redirects to a short-lived signed URL of the user's uploaded avatar, served as
uploaded (no server-side resize: size is accepted for forward compatibility and
ignored). Any authenticated caller — a session or a device — may fetch any user's
avatar by id: a user id is random and not guessable, and the avatar is what the user
chose to be seen by. avatar_not_found when the user has no uploaded avatar (an
unknown id reads the same). The avatar_url in the profile points here and carries a
version parameter that changes with every upload, so a cached image is busted by the
next one.
| user_id required | string Example: usr_pat1234567890123456 Public identifier of the user whose avatar is requested. |
| size | integer Default: 128 Enum: 64 128 256 Requested pixel size. Accepted and ignored; the image is served as uploaded. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the caller's preferred cloud region for new personal links, or null when
no preference is set and placement follows the caller's geography.
{- "preferred_region": "wasabi-eu"
}Sets the cloud region used for new personal links, overriding the geography-based
choice, and returns the stored preference. null clears it and returns placement to
geography. A region this deployment does not serve answers invalid_region.
| preferred_region required | string or null Cloud region for the user's new links. Null clears the preference and returns placement to geography. |
{- "preferred_region": "wasabi-eu"
}{- "preferred_region": "wasabi-eu"
}Returns the account's device inventory: one card per device, with the caller's live sessions on it, its Vault trust standing (trusted, pending or none) and whether it is online. A device is listed while it has a live session of the caller, a trust-circle membership or a pending join request. Another device's pending request is shown only to a caller whose device is in the trust circle.
The response also carries vault_state and, once a recovery key is stored,
recovery. Read-only: revoking a session or trust and deciding a join request stay
on their own endpoints. Requires a user session.
Four cards covering the full surface: the current trusted laptop with a live session; another machine's browser with a session but no trust standing; a phone asking to join (pending block with the request's snapshot); a trusted but logged-out device with no live sessions and minimal registration data.
{- "vault_state": "active",
- "recovery": {
- "updated_at": "2026-08-12T10:03:00Z"
}, - "items": [
- {
- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA=",
- "display_name": "My MacBook",
- "device": {
- "os": "mac",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}, - "is_current": true,
- "online": true,
- "created_at": "2026-06-02T14:12:09Z",
- "last_seen_at": "2026-08-20T09:14:00Z",
- "location": "DE",
- "product_id": "net.shareninja.desktop",
- "trust": {
- "status": "trusted",
- "trusted_at": "2026-06-02T14:12:09Z"
}, - "sessions": [
- {
- "id": "ses_a1b2c3d4e5f6g7h8",
- "created_at": "2026-08-01T08:00:00Z",
- "last_used_at": "2026-08-20T09:10:00Z",
- "is_current": true,
- "ip_address": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
}
]
}, - {
- "agent_id": "agt_b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7",
- "device_fingerprint": "f00dbeef",
- "device_public_key": "MCowBQYDK2VuAyEA7vRZmLxW2kQpHc1nTgUj9iBsDy4EoKfN3aXeM5wPq0A=",
- "display_name": "Chrome on Work PC",
- "device": {
- "os": "win",
- "browser": "chrome"
}, - "is_current": false,
- "online": true,
- "created_at": "2026-08-19T12:00:00Z",
- "last_seen_at": "2026-08-20T08:55:00Z",
- "location": "DE",
- "trust": {
- "status": "none"
}, - "sessions": [
- {
- "id": "ses_b2c3d4e5f6g7h8i9",
- "created_at": "2026-08-19T12:00:05Z",
- "last_used_at": "2026-08-20T08:55:00Z",
- "is_current": false,
- "ip_address": "203.0.113.40",
- "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
]
}, - {
- "agent_id": "agt_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8",
- "device_fingerprint": "9e8d7c6b",
- "device_public_key": "MCowBQYDK2VuAyEAqL0mN8vXjR5tWzEuYc2gKd7oPa1sFh4iB6nUxT3eC0k=",
- "display_name": "Crimson Falcon",
- "device": {
- "os": "ios",
- "model": "iPhone",
- "form_factor": "phone",
- "location": "RS"
}, - "is_current": false,
- "online": false,
- "created_at": "2026-08-20T07:40:00Z",
- "last_seen_at": "2026-08-20T07:45:00Z",
- "location": "RS",
- "product_id": "net.shareninja.ios",
- "trust": {
- "status": "pending",
- "requested_at": "2026-08-20T07:41:00Z",
- "expires_at": "2026-08-21T07:41:00Z",
- "requested_ip": "203.0.113.7",
- "requested_location": "RS"
}, - "sessions": [ ]
}, - {
- "agent_id": "agt_d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9",
- "device_fingerprint": "5a4b3c2d",
- "device_public_key": "MCowBQYDK2VuAyEAx9YtKvPw3mRq7cNjUz1gLd5oBa8sEh2iF4nHxT6eW0c=",
- "display_name": "Old Laptop",
- "device": {
- "os": "other"
}, - "is_current": false,
- "online": false,
- "created_at": "2026-03-11T10:00:00Z",
- "last_seen_at": "2026-05-30T18:20:00Z",
- "trust": {
- "status": "trusted",
- "trusted_at": "2026-03-11T10:05:00Z"
}, - "sessions": [ ]
}
]
}Starts an email change: a 6-digit code goes to the new address, and the response
says where it was sent and when it expires. Nothing changes on the account until
POST /users/me/email/confirm verifies that code, before expires_at.
A verified challenge in X-Auth-Challenge is required; without one the call answers
403 challenge_required. An address already registered to another account answers
email_already_in_use, and a change already awaiting confirmation answers
email_change_pending.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| new_email required | string <email> <= 320 characters The address the account moves to once the code is confirmed. |
{- "new_email": "newemail@example.com"
}{- "status": "pending_verification",
- "verification_sent_to": "newemail@example.com",
- "expires_at": "2026-02-17T12:30:00Z"
}Confirms the pending email change with the 6-digit code sent to the new address. The account's address becomes the new one, every other session of the user is revoked while the calling one stays, and the response reports how many were ended.
A wrong code answers invalid_code and counts against the deployment's attempt
limit, after which no further attempt is accepted (too_many_attempts). Past its
window the change answers email_change_expired; with nothing pending, 404.
| code required | string = 6 characters The 6-digit code sent to the new address. |
{- "code": "123456"
}{- "email": "newemail@example.com",
- "sessions_revoked": 3
}Workspaces, invites, members and roles. Workspaces form a tree; every user gets one at signup. Members hold roles that grant permission scopes.
Returns every workspace the authenticated user created or joined, each with the user's role and effective permissions in it. Ordered by join date, oldest first.
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "workspace_id": "ws_coreworkspace1",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "joined_at": "2024-06-02T14:12:09Z"
}, - {
- "workspace_id": "ws_productops12345",
- "parent_workspace_id": "ws_coreworkspace1",
- "name": "Product Ops",
- "role": "admin",
- "effective_permissions": [
- "workspace:read",
- "workspace:write"
], - "joined_at": "2024-08-11T08:05:44Z"
}
], - "page_info": {
- "next_cursor": null,
- "previous_cursor": null,
- "has_more": false,
- "total_count": 2
}
}Creates a workspace with the caller as its owner, holding the full workspace:* scope,
and returns both the workspace and the caller's membership in it. Pass
parent_workspace_id to nest the workspace under an existing one; omit it for a
root-level workspace.
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| name required | string [ 1 .. 255 ] characters Human-readable workspace name |
| description | string or null <= 1024 characters What the workspace is for. |
| parent_workspace_id | string or null Workspace to nest the new one under. Omit or null for a root workspace. |
{- "name": "Product Ops",
- "description": "Shared space for the product operations crew"
}{- "workspace": {
- "workspace_id": "ws_productops12345",
- "parent_workspace_id": null,
- "name": "Product Ops",
- "description": "Shared space for the product operations crew",
- "inherit_parent_permissions": false,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z"
}, - "membership": {
- "workspace_id": "ws_productops12345",
- "parent_workspace_id": null,
- "name": "Product Ops",
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "joined_at": "2025-05-20T10:25:00Z"
}
}Returns the public side of an invite so the user can see what they are joining before
signing up: workspace name and description, the inviter's name, the role the invite
grants and the current member count. No authentication is needed; the invite token
itself is the authorization. The same preview as GET /invite/{token}, with the token
in the query: an unknown or already redeemed token answers 404 invite_not_found, an
expired or revoked one 410.
| code required | string [ 6 .. 128 ] characters Example: code=t3Kc7QhX0v9mJd2sYw8LpN4uRbF6gHkA1eZiVoCxWqE The invite token to preview |
{- "workspace_name": "Core Workspace",
- "workspace_description": "Primary collaboration space for the core product group.",
- "inviter_name": "Casey Ops",
- "role": "member",
- "member_count": 5
}Returns the public metadata of a workspace invite: the workspace, the inviter, the role on offer and the current member count. The token is the authorization, so the holder can preview the invite without a session.
An invite is redeemed at signup only: the holder passes invite_token to
POST /auth/sessions, and the account is created inside the workspace under the
address the invite was sent to. An unknown or already redeemed token answers 404
invite_not_found, an expired or revoked one 410.
| token required | string [ 6 .. 128 ] characters Example: t3Kc7QhX0v9mJd2sYw8LpN4uRbF6gHkA1eZiVoCxWqE Invite token taken from the invite link. |
{- "workspace_name": "Core Workspace",
- "workspace_description": "Primary collaboration space for the core product group.",
- "inviter_name": "Casey Ops",
- "role": "member",
- "member_count": 5
}Adds the authenticated user to the invited workspace and returns the workspace with the resulting membership. A session or access token is required; the invite token alone is not enough.
Repeating the call for a user who already belongs answers 200 with
status: already_member and changes nothing; a fresh join answers status: joined.
A workspace at its 100-member cap answers 409. An invite that is expired, revoked or
used up answers 410 with invite_expired, invite_revoked or
invite_usage_exhausted.
| token required | string [ 6 .. 128 ] characters Example: inv_b7a3f5e9c2d14a9b8e7f6a3b2c1d0e9f Invite token taken from the invite link. |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "status": "joined",
- "workspace": {
- "workspace_id": "ws_a1b2c3d4e5f6g7h8",
- "name": "Core Workspace",
- "inherit_parent_permissions": false,
- "created_at": "2026-05-01T08:00:00Z",
- "updated_at": "2026-05-01T08:00:00Z"
}, - "membership": {
- "workspace_id": "ws_a1b2c3d4e5f6g7h8",
- "name": "Core Workspace",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "joined_at": "2026-05-14T10:00:00Z"
}
}Returns one workspace. The caller must be a member; a workspace the caller cannot reach is answered 404, so its existence is never revealed.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace |
{- "workspace_id": "ws_7c3a1e5d-9f2b-4d6e-8a1c-3e5b9d7f2a4c",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "description": "Primary collaboration space for the core product group.",
- "inherit_parent_permissions": false,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z",
- "preferred_region": "wasabi-eu"
}Changes the name, the description, or both. Requires the workspace:write scope or
owner rights on the workspace. Returns the updated workspace.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace to update |
| name | string [ 1 .. 255 ] characters New name for the workspace |
| description | string or null <= 1024 characters New description. Send null to clear it. |
| inherit_parent_permissions | boolean When true, members inherit permissions from the parent workspace. |
{- "name": "Renamed Workspace",
- "description": "Updated description"
}{- "workspace_id": "ws_7c3a1e5d-9f2b-4d6e-8a1c-3e5b9d7f2a4c",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "description": "Primary collaboration space for the core product group.",
- "inherit_parent_permissions": false,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z",
- "preferred_region": "wasabi-eu"
}Deletes a workspace and the data attached to it: every membership is removed, every
invite it issued stops working, and its resources are cleaned up. Requires owner rights
(workspace:* or the workspace:own scope). The caller's last remaining workspace
cannot be deleted. A workspace that still has child workspaces, active links or billing
descendants answers 409.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace to delete |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the workspace's preferred cloud region. While it is set, new links in the workspace use it instead of the region chosen from the caller's location.
| workspace_id required | string Example: ws_9f63b7a0d6f54cb5 Workspace public ID |
{- "preferred_region": "wasabi-eu"
}Sets the cloud region new links in the workspace use, overriding the region chosen from
the caller's location. Send preferred_region: null to clear it and go back to that
default. Requires the workspace:write scope or owner rights. An unknown region name
answers 400 invalid_region.
| workspace_id required | string Example: ws_9f63b7a0d6f54cb5 Workspace public ID |
| preferred_region required | string or null Cloud region for the user's new links. Null clears the preference and returns placement to geography. |
{- "preferred_region": "wasabi-eu"
}{- "preferred_region": "wasabi-eu"
}Returns the workspace's live invites: id, the address each was sent to, the role it
grants when redeemed and its expiry. The invite token itself travels only in the
mail; reads show its tail as code_hint.
| workspace_id required | string Example: ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f Identifier of the workspace whose invites should be returned |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "invite_id": "inv_018f7c41-5a2b-7c3d-8e9f-1a2b3c4d5e6f",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "code_hint": "...OPS",
- "recipient_email": "casey@example.com",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "created_by_user_id": "usr_6f1a9c3e-8b2d-4f7a-9c1e-5d3b7a2f8e4c",
- "created_at": "2025-05-20T09:00:00Z",
- "expires_at": "2025-06-20T09:00:00Z"
}, - {
- "invite_id": "inv_018f7c41-6b3c-7d4e-9f0a-2b3c4d5e6f7a",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "code_hint": "...JOIN",
- "recipient_email": "sam@example.com",
- "role": "admin",
- "effective_permissions": [
- "workspace:read",
- "workspace:write"
], - "created_by_user_id": "usr_6f1a9c3e-8b2d-4f7a-9c1e-5d3b7a2f8e4c",
- "created_at": "2025-05-19T12:00:00Z",
- "expires_at": "2025-06-19T12:00:00Z"
}
], - "page_info": {
- "next_cursor": "g2wAAAABDABBB",
- "previous_cursor": null,
- "has_more": true,
- "total_count": 3
}
}Invites one person, by address, to create an account in the workspace. The invite is
mailed to recipient_email and redeemed once, at signup with its token; the account
is created under that address, whatever the recipient types. An address that already
has an account answers 409 email_already_in_use: an account belongs to its own
workspace and cannot be added to another.
The invite's role is what the new member gets, and only an owner can create an
owner-level invite, otherwise the answer is 403. expires_at sets when the invite
stops working; omitted, the server's default lifetime applies.
| workspace_id required | string Example: ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f Identifier of the workspace for which the invite will be created |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| recipient_email required | string <email> Address to mail the invite to; the account is created under it. |
| role | string or null Role the invite grants, one of the application's roles; omitted, the application's default. |
| expires_at | string or null <date-time> When the invite stops working. Omitted, the server's default lifetime applies. |
{- "recipient_email": "casey@example.com",
- "role": "member",
- "expires_at": "2025-06-20T09:00:00Z"
}{- "invite_id": "inv_018f7c41-5a2b-7c3d-8e9f-1a2b3c4d5e6f",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "code_hint": "...OPS",
- "recipient_email": "casey@example.com",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "created_by_user_id": "usr_6f1a9c3e-8b2d-4f7a-9c1e-5d3b7a2f8e4c",
- "created_at": "2025-05-20T09:00:00Z",
- "expires_at": "2025-06-20T09:00:00Z"
}Revokes an invite. Its token stops working immediately, so the recipient can no longer sign up with it.
| workspace_id required | string Example: ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f Identifier of the workspace that issued the invite |
| invite_id required | string Example: inv_018f7c41-5a2b-7c3d-8e9f-1a2b3c4d5e6f Identifier of the invite to revoke |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the workspace's members, each with their id, email and display name, their role
and effective permissions, the date they joined and who invited them. Ordered by join
date. Requires at least the workspace:read scope.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace whose members should be returned |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "user_id": "usr_pat1234567890123456",
- "email": "pat@example.com",
- "display_name": "Pat Manager",
- "role": "owner",
- "effective_permissions": [
- "workspace:read",
- "workspace:write",
- "workspace:own"
], - "joined_at": "2024-06-02T14:12:09Z",
- "invited_by_user_id": null,
- "cascade_to_children": false,
- "identity_public_key": null
}, - {
- "user_id": "usr_casey123456789012345",
- "email": "casey@example.com",
- "display_name": "Casey Ops",
- "role": "admin",
- "effective_permissions": [
- "workspace:read",
- "workspace:write"
], - "joined_at": "2024-08-15T10:45:00Z",
- "invited_by_user_id": "usr_pat1234567890123456",
- "cascade_to_children": false,
- "identity_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA="
}
], - "page_info": {
- "next_cursor": "g2wAAAABBmV2ZW50MQ",
- "previous_cursor": null,
- "has_more": true,
- "total_count": 5
}
}Adds a known user to the workspace directly, without an invite, and returns the new
membership. The role granted cannot exceed the caller's own. A user who is already a
member answers 409 user_already_workspace_member.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace to which the member will be added |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| user_id required | string User to add to the workspace |
| role | string or null Enum: "member" "admin" "owner" Role to give the user. Defaults to |
| cascade_to_children | boolean When true, the member also gains access to every child workspace. |
| send_welcome_email | boolean When true, the new member receives a welcome message. |
{- "user_id": "usr_a1b2c3d4e5f6g7h8i9j0",
- "role": "member",
- "send_welcome_email": true
}{- "user_id": "usr_casey123456789012345",
- "email": "casey@example.com",
- "display_name": "Casey Ops",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "joined_at": "2025-05-21T09:30:00Z",
- "invited_by_user_id": "usr_pat1234567890123456",
- "cascade_to_children": false
}Returns per-member activity for the workspace: links created, upload and download counts
and storage used. Only links owned by this workspace are counted. Requires the
workspace:read scope or owner rights.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace |
{- "items": [
- {
- "user_id": "usr_pat1234567890123456",
- "links_created": 12,
- "uploads_count": 8,
- "downloads_count": 25,
- "storage_bytes": 1073741824
}, - {
- "user_id": "usr_casey123456789012345",
- "links_created": 3,
- "uploads_count": 2,
- "downloads_count": 10,
- "storage_bytes": 524288000
}
]
}Returns one member of the workspace with their role and effective permissions. The caller must be a member of the workspace.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace |
| user_id required | string Example: usr_a1b2c3d4e5f6g7h8i9j0 Identifier of the user whose membership to retrieve |
{- "user_id": "usr_a4e7c2d9-3b1f-4e6a-8d5c-2f9b7e1a6c3d",
- "email": "casey@example.com",
- "display_name": "Casey Ops",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "joined_at": "2024-08-15T10:45:00Z",
- "invited_by_user_id": "usr_c8d2f5a1-7e3b-4c9d-a6f2-1b4e8d7c5a9f",
- "cascade_to_children": false,
- "identity_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA="
}Changes a member's role (member, admin or owner); the result is returned as the
updated membership. A member's permissions are their role's — there are no
per-member extras.
Requires the workspace:write scope or owner rights. The caller cannot grant more than
they hold themselves, and only an owner may change another owner. The workspace must
keep at least one owner, so downgrading the last one answers 403
workspace_must_have_at_least_one_owner.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace that contains the member |
| user_id required | string Example: usr_a1b2c3d4e5f6g7h8i9j0 Identifier of the user whose membership will be updated |
| role required | string Role to give the user; the application's role names. |
| cascade_to_children | boolean When true, the member's permissions also apply in every child workspace. |
{- "role": "member"
}{- "user_id": "usr_a4e7c2d9-3b1f-4e6a-8d5c-2f9b7e1a6c3d",
- "email": "casey@example.com",
- "display_name": "Casey Ops",
- "role": "member",
- "effective_permissions": [
- "workspace:read"
], - "joined_at": "2024-08-15T10:45:00Z",
- "invited_by_user_id": "usr_c8d2f5a1-7e3b-4c9d-a6f2-1b4e8d7c5a9f",
- "cascade_to_children": false,
- "identity_public_key": "MCowBQYDK2VuAyEAq8Gk1GMnfN3WjLKBHaOvhB0tXRuQbVJLBqFz5mFqHQA="
}Removes a user from the workspace; their access ends immediately. Requires the
workspace:write scope or owner rights, except that a member may always remove
themselves. Only an owner may remove another owner, and the last owner cannot be
removed — hand ownership over first.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the workspace that contains the member |
| user_id required | string Example: usr_a1b2c3d4e5f6g7h8i9j0 Identifier of the user whose membership will be removed |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Returns the roles a workspace offers: the preset ones (owner, admin, member),
which cannot be changed or deleted, and any custom roles defined for this workspace with
their own permission sets.
| workspace_id required | string Example: ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f Workspace public ID |
[- {
- "role_id": "role_123",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "role_key": "reviewer",
- "display_name": "Code Reviewer",
- "permissions": [
- "workspace:read",
- "member:manage"
], - "is_preset": false,
- "created_at": "2025-01-15T10:30:00Z"
}
]Creates a custom role in the workspace and returns it. role_key must match
[a-z][a-z0-9_]* and be unique within the workspace.
| workspace_id required | string Workspace public ID |
| role_key required | string^[a-z][a-z0-9_]*$ Unique identifier for the role within the workspace |
| display_name required | string Human-readable name |
| permissions required | Array of strings Permission scopes granted to this role |
{- "role_key": "reviewer",
- "display_name": "Code Reviewer",
- "permissions": [
- "workspace:read",
- "member:manage"
]
}{- "role_id": "role_123",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "role_key": "reviewer",
- "display_name": "Code Reviewer",
- "permissions": [
- "workspace:read",
- "member:manage"
], - "is_preset": false,
- "created_at": "2025-01-15T10:30:00Z"
}Returns one role of the workspace with its permission set.
| workspace_id required | string Workspace public ID |
| role_id required | string Example: role_123 Role ID (role_{id} format) |
{- "role_id": "role_123",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "role_key": "reviewer",
- "display_name": "Code Reviewer",
- "permissions": [
- "workspace:read",
- "member:manage"
], - "is_preset": false,
- "created_at": "2025-01-15T10:30:00Z"
}Changes a custom role's display name or replaces its permission set. Preset roles cannot be changed.
| workspace_id required | string Workspace public ID |
| role_id required | string Role ID |
| display_name | string New display name |
| permissions | Array of strings New permission set (replaces existing) |
{- "display_name": "string",
- "permissions": [
- "string"
]
}{- "role_id": "role_123",
- "workspace_id": "ws_2b9e4f7a-1c3d-4a8b-9f6e-7d2c5b1a3e9f",
- "role_key": "reviewer",
- "display_name": "Code Reviewer",
- "permissions": [
- "workspace:read",
- "member:manage"
], - "is_preset": false,
- "created_at": "2025-01-15T10:30:00Z"
}Deletes a custom role; members holding it lose the permissions it carried. Preset roles cannot be deleted.
| workspace_id required | string Workspace public ID |
| role_id required | string Role ID |
{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Returns the workspaces nested directly under the given one. Only immediate children come back; walk deeper by calling this again for each child.
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Identifier of the parent workspace whose children should be listed |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "workspace_id": "ws_productops12345",
- "parent_workspace_id": "ws_coreworkspace1",
- "name": "Product Ops",
- "description": "Shared space for the product operations crew",
- "inherit_parent_permissions": false,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z"
}, - {
- "workspace_id": "ws_custsuccess1234",
- "parent_workspace_id": "ws_coreworkspace1",
- "name": "Customer Success",
- "description": null,
- "inherit_parent_permissions": false,
- "created_at": "2025-05-22T08:15:00Z",
- "updated_at": "2025-05-22T08:15:00Z"
}
], - "page_info": {
- "next_cursor": null,
- "previous_cursor": "g2wAAAABZXZlbnQy",
- "has_more": false,
- "total_count": 2
}
}Returns the workspace's current usage counters: storage, transfer bytes this month and
active links. The counters cover the whole billing subtree the workspace belongs to,
while storage_bytes_workspace reports this workspace alone. Add ?breakdown=true for
per-workspace storage across that subtree. Requires the workspace:read permission; a
workspace the caller cannot reach is answered 404.
| workspace_id required | string Example: ws_coreworkspace1 Workspace identifier |
| breakdown | boolean Default: false Example: breakdown=true When true, the response also carries |
{- "principal_id": "ws_coreworkspace1",
- "counters": {
- "storage_bytes": 10737418240,
- "transfer_bytes_per_month": 5368709120,
- "link_max_count": 50
}, - "storage_bytes_workspace": 5368709120,
- "updated_at": "2025-02-03T10:30:00Z"
}Returns the limits in force for the workspace once tier defaults, workspace overrides and
feature flags have been applied, together with the tier it sits on and how many overrides
it carries. A limit of null means unlimited. Requires the workspace:read permission;
a workspace the caller cannot reach is answered 404.
| workspace_id required | string Example: ws_coreworkspace1 Workspace identifier |
{- "limits": {
- "storage_bytes": 107374182400,
- "transfer_bytes_per_month": 536870912000,
- "link_max_count": 1000,
- "file_max_size_bytes": 10737418240,
- "concurrent_transfers": 50,
- "link_max_ttl_seconds": null
}, - "tier_slug": "enterprise",
- "tier_name": "Enterprise Plan",
- "override_count": 1,
- "features": {
- "download_limit": true,
- "notify_on_open": true,
- "notify_on_download": true
}
}Redirects to the workspace's avatar image in one of the sizes 64, 128 or 256 pixels, 128 by default. A workspace with no uploaded avatar answers 404. The response carries cache headers, and the redirect URL changes whenever the avatar does, so a cached copy is never served for a new image.
| workspace_id required | string Example: ws_coreworkspace1 Workspace identifier |
| size | integer Default: 128 Enum: 64 128 256 Avatar size in pixels |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Uploads or replaces the workspace's avatar, sent as a base64 data URL, and returns the
updated workspace. Accepted formats are JPEG, PNG, WebP and GIF; the decoded image may
not exceed the deployment's size cap, 5 MB by default. Requires the workspace:write
scope.
| workspace_id required | string Example: ws_coreworkspace1 Workspace identifier |
| avatar required | string The image as a data URL, |
{- "avatar": "data:image/png;base64,iVBORw0KGgo..."
}{- "workspace_id": "ws_7c3a1e5d-9f2b-4d6e-8a1c-3e5b9d7f2a4c",
- "parent_workspace_id": null,
- "name": "Core Workspace",
- "description": "Primary collaboration space for the core product group.",
- "inherit_parent_permissions": false,
- "created_at": "2025-05-20T10:25:00Z",
- "updated_at": "2025-05-20T10:25:00Z",
- "preferred_region": "wasabi-eu"
}Removes the workspace's avatar. Requires the workspace:write scope.
| workspace_id required | string Example: ws_coreworkspace1 Workspace identifier |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}End-to-end encryption keys: the identity key pair, the trust circle of devices, share
keys wrapped per recipient, recovery, and claims for anonymous sharing. Clients read
GET /vault/config on startup for the active cipher suite.
Returns the cipher suite configuration for vault end-to-end encryption. Clients
read it on startup and follow three rules: encrypt new blobs with
latest_suite_version, decrypt any suite they know, and treat an unknown version
as a hard error rather than falling back.
{- "min_suite_version": 1,
- "latest_suite_version": 1
}Superseded by GET /users/me/devices, where trusted devices are the cards with
trust.status: trusted. Returns the devices in the caller's trust circle. Only a
device inside the circle may read the roster.
{- "items": [
- {
- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_name": "MacBook Pro",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z",
- "is_current": true,
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}
]
}Registers the calling device as the first device of the trust circle and stores the
user's identity keypair. Needs a login session, not trust circle membership. A user
that already has an identity key answers 409 vault_already_initialized.
| device_public_key required | string <byte> <= 64 characters X25519 device public key (base64) — exactly 32 bytes after decoding |
| encrypted_identity_key required | string <byte> <= 1024 characters Identity private key wrapped to the device public key (hybrid encryption). A blob under 61 decoded bytes — suite byte, ephemeral public key, IV and GCM tag — is refused as validation_failed. |
| identity_public_key required | string <byte> <= 64 characters Identity X25519 public key (base64) — exactly 32 bytes after decoding |
| encrypted_identity_key_for_recovery | string <byte> <= 1024 characters Identity private key encrypted with the recovery master key. Optional; can be set later with PUT /vault/recovery. Under 28 decoded bytes (IV and GCM tag) it is refused as validation_failed. |
{- "device_public_key": "string",
- "encrypted_identity_key": "string",
- "identity_public_key": "string",
- "encrypted_identity_key_for_recovery": "string"
}{- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_name": "MacBook Pro",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z",
- "is_current": true,
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}Submits a join request from the calling device. No body: the device's public key
and name are the calling agent's own, already known from its registration. Needs a
login session, not trust circle membership; the vault must already be initialized.
Every trusted device receives a vault.device_join_requested frame, and the
response lists the same devices as approvers, so the asking device can show where
to confirm.
Asking again is safe and is the way out of a stale ask: the account keeps one live
request per device, so a repeat returns the standing request with the same
request_id and the deadline pushed out. A repeat also drops the
sign_in_peer_agent_id mark of a request the server opened for a consent browser,
so a request only the signed-in device was shown reaches the whole circle. A device
that switched accounts asks each vault independently.
{- "request_id": "string",
- "device_fingerprint": "string",
- "status": "pending",
- "expires_at": "2019-08-24T14:15:22Z",
- "approvers": [
- {
- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}, - "product_id": "net.shareninja.ios",
- "online": true,
- "last_seen_at": "2019-08-24T14:15:22Z"
}
]
}Superseded by GET /users/me/devices, where pending requests are the cards with
trust.status: pending, carrying the request snapshot and the device key needed to
approve. Returns the join requests awaiting a decision in the caller's trust
circle. Only a device inside the circle may read them and decide them.
{- "items": [
- {
- "request_id": "string",
- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_public_key": "string",
- "device_fingerprint": "string",
- "device_name": "string",
- "ip": "string",
- "country": "string",
- "display_name": "string",
- "product_id": "net.shareninja.ios",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}, - "sign_in_peer_agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "status": "pending",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z"
}
]
}Approves a pending join request. The caller must itself be a trusted device and
supplies encrypted_identity_key — the identity private key wrapped to the
requesting device's public key. The requester receives vault.device_approved
carrying that wrap; the remaining trusted devices receive
vault.device_approved_broadcast so their device lists refresh.
A request another trusted device already decided answers 409
join_request_already_decided; one already rejected or expired answers 410.
| agent_id required | string (AgentID) [ 8 .. 256 ] characters ^agt_[A-Za-z0-9_-]+$ Example: agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 Unique agent identifier, prefixed with |
| encrypted_identity_key required | string <byte> <= 1024 characters Identity private key wrapped to the new device's public key (hybrid encryption). Under the 61-decoded-byte floor it is refused as validation_failed. |
{- "encrypted_identity_key": "string"
}{- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_name": "MacBook Pro",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z",
- "is_current": true,
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}Rejects a pending join request. The caller must itself be a trusted device. The
rejected device receives vault.device_rejected by direct push; the user's trusted
devices receive vault.device_rejected_broadcast so their pending lists drop the
request. The optional reason travels only to the rejected device, as written, and
is recorded in the audit log.
| agent_id required | string (AgentID) [ 8 .. 256 ] characters ^agt_[A-Za-z0-9_-]+$ Example: agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 Unique agent identifier, prefixed with |
| reason | string [ 1 .. 500 ] characters Free-text rejection reason chosen by the rejecting device's user. |
{- "reason": "string"
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Removes a device from the trust circle and deletes its wrapped copy of the identity
key. The revoked device receives vault.device_self_revoked, loses its
subscriptions, its sessions and its socket; every remaining trusted device receives
vault.device_revoked. The caller must itself be a trusted device and cannot
revoke the one holding the current session (409 cannot_revoke_current_device).
| agent_id required | string (AgentID) [ 8 .. 256 ] characters ^agt_[A-Za-z0-9_-]+$ Example: agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 Unique agent identifier, prefixed with |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Reports whether a recovery-encrypted identity key exists for the user. Never
returns the blob itself — that is POST /vault/recovery/recover.
Any login session of the user may ask, trusted or not: a device that lost the trust circle needs this answer to know whether recovery is an option or reset is the only way back.
{- "configured": true,
- "updated_at": "2019-08-24T14:15:22Z"
}Stores the recovery-encrypted identity key, replacing any existing one for the caller. Requires trust circle membership. The same payload covers both first setup and regeneration; the caller does not need to know which case applies.
| encrypted_identity_key_for_recovery required | string <byte> <= 1024 characters Identity private key encrypted with the recovery master key (symmetric AES-256-GCM). Under 28 decoded bytes (IV and GCM tag) it is refused as validation_failed. |
{- "encrypted_identity_key_for_recovery": "string"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the recovery-encrypted identity key. Needs a login session, not trust
circle membership. Decrypt it with the recovery key, re-encrypt the identity
private key under the new device's public key, then call
POST /vault/recovery/complete to finish. A user that never stored a recovery key
answers 404 recovery_not_set_up.
| device_public_key required | string <byte> <= 64 characters New device's X25519 public key — exactly 32 bytes after decoding |
{- "device_public_key": "string"
}{- "encrypted_identity_key_for_recovery": "string"
}Finishes the flow started by POST /vault/recovery/recover: submit the identity
key decrypted with the recovery words and re-encrypted under the new device's
public key. The submitted identity_public_key must match the user's stored one;
every device in the circle is then revoked, losing its sessions and sockets, and
the calling device is enrolled as the sole trusted one. The identity is
unchanged, so wrapped share keys survive.
Needs a login session and a fresh factor in X-Auth-Challenge, not trust circle
membership. A caller already in the circle answers 409 device_already_trusted.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| device_public_key required | string <byte> <= 64 characters X25519 public key of the new device (base64) — exactly 32 bytes after decoding. |
| encrypted_identity_key required | string <byte> <= 1024 characters Identity private key, decrypted with the recovery words and re-encrypted under device_public_key (base64); under the 61-decoded-byte floor it is refused as validation_failed. |
| identity_public_key required | string <byte> <= 64 characters Identity X25519 public key — exactly 32 bytes after decoding. Must match the user's stored one; it binds the call to the right vault. |
{- "device_public_key": "string",
- "encrypted_identity_key": "string",
- "identity_public_key": "string"
}{- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_name": "MacBook Pro",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z",
- "is_current": true,
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}The way out when both the trust circle and the recovery key are lost. One transaction swaps in the submitted identity, empties the circle, discards the caller's wrapped share keys, deletes every link the account owns, replaces the recovery key with the submitted wrap or deletes it when none is sent, and enrols the caller as the sole trusted device; the replaced devices lose their sessions and sockets.
Each deleted link is taken down like any other: subscribers hear link.deleted,
and its recipients lose access. Links a device created before signing in belong to
the device and stay. Grants and workspace membership survive, so the holder of a
link someone else shares with the caller can re-wrap its key through the
pending-key bridge.
Needs a fresh factor in X-Auth-Challenge; re-submitting the stored
identity_public_key is refused as validation_failed.
| X-Auth-Challenge | string [ 16 .. 64 ] characters Example: ch_wjIilzM03bjfJhYdEPkGN9CAZdlx_AO5 Challenge token verified with |
| device_public_key required | string <byte> <= 64 characters X25519 public key of the new device (base64) — exactly 32 bytes after decoding. |
| identity_public_key required | string <byte> <= 64 characters New identity X25519 public key (base64) — exactly 32 bytes after decoding. Must differ from the stored one, which is device recovery rather than a reset. |
| encrypted_identity_key required | string <byte> <= 1024 characters New identity private key wrapped to device_public_key (base64); under the 61-decoded-byte floor it is refused as validation_failed. |
| encrypted_identity_key_for_recovery | string <byte> <= 1024 characters New identity private key encrypted with a new recovery master key (base64). Optional: without it the stored recovery key is deleted. Under 28 decoded bytes it is refused as validation_failed. |
{- "device_public_key": "string",
- "identity_public_key": "string",
- "encrypted_identity_key": "string",
- "encrypted_identity_key_for_recovery": "string"
}{- "agent_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "device_name": "MacBook Pro",
- "device_fingerprint": "a1b2c3d4",
- "device_public_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "last_seen_at": "2019-08-24T14:15:22Z",
- "is_current": true,
- "display_name": "string",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}Writes wrapped share keys into the vault key-store for one or more (link,
recipient) pairs — all rows or none. Key delivery only: no scopes and no ACL
changes. Any key holder able to read the link may deliver, and delivery never
widens access: a row is written only for a recipient that already reaches the link,
and a keyless target answers 404 pending_key_not_found.
The key-store is insert-only. The first delivery for a pair wins, a replay with
identical bytes counts as delivered, and different bytes answer 409
vault_key_conflict and roll the whole batch back.
required | Array of objects (VaultKeyBatchItem) [ 1 .. 100 ] items |
{- "items": [
- {
- "url_token": "k7mstq3w",
- "subject_id": "usr_a1b2c3d4e5f6g7h8i9j0",
- "encrypted_share_key": "string",
- "identity_public_key": "string"
}
]
}{- "delivered": 0
}Deletes the caller's own key-store row for the link and re-queues the caller as authorized-but-keyless, so an online key holder delivers a fresh wrap. This is the repair path for a key that fails to decrypt. Only the caller's own row can be reset; another recipient's key is not reachable through any endpoint.
| url_token required | string Link URL token |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the pending key requests for links the caller holds a key for, as owner or
as a recipient with a key-store row — the catch-up companion to the
vault.share_key_needed frame, for a holder that was offline when the request was
queued. Fulfil them with POST /vault/keys/batch.
| workspace_id | string Optional filter: only pending keys for links owned by this workspace. Omitted → all pending keys the caller can fulfil. |
| cursor | string Cursor from previous response for pagination |
| limit | integer [ 1 .. 100 ] Default: 100 Maximum number of items to return |
{- "items": [
- {
- "id": "vpk_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
- "user_id": "usr_a1b2c3d4e5f6g7h8i9j0",
- "identity_public_key": "string",
- "workspace_id": "ws_a1b2c3d4e5f6g7h8",
- "url_token": "string",
- "status": "pending",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "cursor": "string"
}Attaches a claim to a link, so that whoever holds the raw claim token can unwrap
the link's share key. The client generates the claim token locally, derives an
encryption key from it with HKDF, encrypts the share key with that, and sends only
claim_token_hash and encrypted_share_key; the server never sees the raw token.
An agent session is enough — no user login is required. The server checks that
url_token exists and caps expires_at at 30 days.
| url_token required | string URL token of the link to attach the claim to |
| claim_token_hash required | string <byte> [ 1 .. 64 ] characters SHA256(claim_token), base64-encoded — the server stores only the hash |
| encrypted_share_key required | string <byte> [ 1 .. 1024 ] characters Share key encrypted with the AES key derived from HKDF(claim_token), base64 |
| expires_at | string <date-time> Requested expiry. Capped at 30 days from now; absent means the cap. |
{- "url_token": "k7mstq3w",
- "claim_token_hash": "string",
- "encrypted_share_key": "string",
- "expires_at": "2019-08-24T14:15:22Z"
}{- "expires_at": "2019-08-24T14:15:22Z"
}Returns the claim's encrypted share key. The client sends SHA256 of its raw claim token as the path parameter and decrypts the returned key locally with HKDF(claim_token); the server only ever sees the hash. An agent session is enough — no user login is required. An expired or unknown claim answers 404.
| claim_token_hash required | string [ 1 .. 64 ] characters SHA256(claim_token), base64url-encoded (the server base64url-decodes it) |
{- "url_token": "string",
- "encrypted_share_key": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z"
}Turns an anonymous claim into a permanent grant for the calling user: the client re-encrypts the share key under its own identity public key, and the server writes the read grant and the key-store row together. Requires a login session and an initialized vault.
The claim is spent by the call — only an unclaimed, unexpired claim can be bound,
and one already bound answers 409 claim_already_exists.
| claim_token_hash required | string [ 1 .. 64 ] characters SHA256(claim_token), base64url-encoded (the server base64url-decodes it) |
| encrypted_share_key required | string <byte> <= 1024 characters Share key re-encrypted under the user's identity public key (base64) |
{- "encrypted_share_key": "string"
}{- "url_token": "string",
- "status": "claimed"
}Ephemeral links to shared content. A link has one or two datasources: p2p (direct
WebRTC transfer between online agents) and cloud (per-file presigned URLs to object
storage). A link may expire after a validity period; a cloud link is bounded by the
owner tier's retention.
Returns the links the caller can reach, newest first: the ones they own and the ones an ACL grants them.
Without workspace_id the result spans every workspace the caller is a member of plus
their personal links; with it the result is limited to that workspace and a non-member
answers 403. Each link carries its own workspace_id.
Page with cursor for sequential reading or offset for page-number UIs.
| workspace_id | string Example: workspace_id=ws_a1b2c3d4e5f6g7h8 Restrict the result to one workspace by its public ID. When omitted, links from every workspace the caller is a member of are returned together. |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "links": [
- {
- "url_token": "oth5mu1D",
- "url": "/oth5mu1D/",
- "description": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string",
- "user": {
- "user_id": "usr_abc123",
- "email": "user@example.com",
- "display_name": "John Doe"
}, - "agent": {
- "agent_id": "agt_xyz789",
- "name": "MacBook Pro",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}, - "workspace_id": "ws_a1b2c3d4e5f6g7h8",
- "effective_permissions": [
- "link:read",
- "link:write",
- "link:manage"
], - "created_at": "2024-01-15T10:30:00Z",
- "invalidate_at": "2024-01-16T10:30:00Z",
- "deleted_at": "2024-01-17T10:30:00Z",
- "size": {
- "estimated_bytes": 104857600,
- "actual_bytes": 98765432
}, - "download_count": 42,
- "download_limit": 1,
- "notify_on_open": true,
- "notify_on_download": true,
- "cloud": true,
- "p2p": false
}
], - "has_more": true,
- "next_cursor": "string"
}Creates an ephemeral link with the datasources it asks for: p2p_storage for direct
WebRTC transfer between agents, cloud_storage for object storage reached through
per-file presigned URLs.
A registered caller passes workspace_id, which decides the storage pool, billing and
admin controls; an anonymous agent omits it and owns the link on the Anonymous tier.
validity is the lifetime in seconds, 0 meaning no expiry; on a cloud link it is
clamped to the owner tier's retention, which an omitted validity takes. 206 means a
requested datasource was not provisioned.
| workspace_id | string Public ID of the workspace that owns the link: its storage pool, billing, ACL-targetable members and admin controls. An anonymous agent omits it and owns the link itself. |
| description | string <= 1024 characters Share description |
| validity | integer >= 0 Link validity in seconds; 0 means the link never expires. Capped at the owner tier's retention maximum; absent, the link gets that maximum. |
| cloud_storage | boolean Default: false Provision cloud storage for the link |
| p2p_storage | boolean Default: true Allow direct P2P transfer for the link |
| instance_id | string^[-a-zA-Z0-9_]{1,64}$ Runtime instance that serves this link's P2P bytes, as registered with
|
| public | boolean Default: true Let anyone holding the link download from it |
| provider | string Cloud provider alias |
| estimated_bytes | integer <int64> >= 0 Declared total upload size in bytes for the link, surfaced as
|
| encrypted_metadata | string <byte> <= 65536 characters Opaque base64 blob encrypted by the client with the link share key. Stored as-is and never interpreted by the server. |
| encrypted_share_key | string <byte> <= 1024 characters The link's share key wrapped with the owner's identity public key, base64; it
comes back on |
| download_limit | integer <int64> >= 1 Ceiling on download sessions for the link; 1 makes it single-use. Paid tiers
only: a Free or Anonymous owner answers 402 |
| notify_on_open | boolean Default: false Email the owner on every download session start. Paid tiers only: a Free or
Anonymous owner answers 402 |
| notify_on_download | boolean Default: false Email the owner on every file downloaded (a successful file-transfer end in a
download session). Paid tiers only: a Free or Anonymous owner answers 402
|
{- "workspace_id": "ws_abc123",
- "description": "string",
- "validity": 3600,
- "cloud_storage": false,
- "p2p_storage": true,
- "instance_id": "tab-9f2c4b1a",
- "public": true,
- "provider": "wasabi",
- "estimated_bytes": 104857600,
- "encrypted_metadata": "string",
- "encrypted_share_key": "string",
- "download_limit": 1,
- "notify_on_open": false,
- "notify_on_download": false
}{- "url_token": "oth5mu1D",
- "url": "/oth5mu1D/",
- "retention_clamped": true,
- "requested_retention_sec": 0,
- "max_allowed_retention_sec": 0
}Returns the link's metadata, its datasources and the caller's effective_permissions.
Requires link:read, held through ownership or an ACL grant. The write token is
returned to the link owner only.
| url_token required | string Example: oth5mu1D Link URL token |
| claim_token_hash | string [ 1 .. 64 ] characters SHA256 of a claim token, base64url-encoded; the server never sees the raw token. When the claim refers to this link, A claim that is unknown, expired or refers to another link is ignored, and
|
{- "url_token": "oth5mu1D",
- "url": "/oth5mu1D/",
- "description": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string",
- "user": {
- "user_id": "usr_abc123",
- "email": "user@example.com",
- "display_name": "John Doe"
}, - "agent": {
- "agent_id": "agt_xyz789",
- "name": "MacBook Pro",
- "device": {
- "os": "mac",
- "browser": "safari",
- "model": "MacBookPro18,1",
- "form_factor": "laptop",
- "location": "DE"
}
}, - "workspace_id": "ws_a1b2c3d4e5f6g7h8",
- "effective_permissions": [
- "link:read",
- "link:write",
- "link:manage"
], - "created_at": "2024-01-15T10:30:00Z",
- "invalidate_at": "2024-01-16T10:30:00Z",
- "deleted_at": "2024-01-17T10:30:00Z",
- "size": {
- "estimated_bytes": 104857600,
- "actual_bytes": 98765432
}, - "download_count": 42,
- "download_limit": 1,
- "notify_on_open": true,
- "notify_on_download": true,
- "cloud": true,
- "p2p": false
}Updates the link's description, encrypted metadata, validity, declared size or
datasources. Requires link:manage.
Datasource flags are additive: cloud_storage: true adds a cloud datasource when the
link has none, and no datasource is ever removed. Setting validity restarts the
lifetime from now.
| url_token required | string Link URL token |
| description | string <= 1024 characters |
| encrypted_metadata | string <byte> <= 65536 characters Opaque base64 blob encrypted by the client with the link share key. Stored as-is and never interpreted by the server. |
| validity | integer >= 0 Link validity in seconds from now; 0 means the link never expires. Capped at the link's created_at plus the owner tier's retention maximum, so a patch cannot extend a link past the window it was born with. |
| cloud_storage | boolean |
| p2p_storage | boolean |
| provider | string |
| estimated_bytes | integer <int64> >= 0 Re-declared total upload size in bytes. Checked against the owner's storage
cap with the link's own stored bytes excluded; a declaration past the
remainder answers 409 |
| download_limit | integer <int64> >= 0 Ceiling on download sessions; 0 removes the limit. Setting a limit needs a
paid tier on the owner, else 402 |
| notify_on_open | boolean Email the owner on every download session start. Turning it on needs a paid
tier on the owner, else 402 |
| notify_on_download | boolean Email the owner on every file downloaded (a successful file-transfer end in a
download session). Turning it on needs a paid tier on the owner, else 402
|
{- "description": "string",
- "encrypted_metadata": "string",
- "validity": 0,
- "cloud_storage": true,
- "p2p_storage": true,
- "provider": "string",
- "estimated_bytes": 104857600,
- "download_limit": 1,
- "notify_on_open": true,
- "notify_on_download": true
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Removes the link and its datasources. Requires link:manage. Cloud storage under the
link's prefix is cleaned up afterwards, and the link can be brought back with
POST /links/{url_token}/restore while it is still inside its undo window.
| url_token required | string Link URL token |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Reverses a delete while the link is still inside its undo window: the link comes back
with the bytes it still holds, reappears in the owner's list and is announced again as
link.created. Bytes already purged are not restored. Requires link:manage, the same
permission as the delete it undoes.
A caller who may not manage the link, an unknown token, a link that was never deleted
and one whose bytes are already gone all answer 404 link_not_found.
| url_token required | string Link URL token |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Files an abuse report on a link from anyone holding its token; no session is required. A signed-in reporter is identified by their account, an anonymous one by their IP.
Attachments are evidence the reporter chose to show from content already decrypted on
their side. link_key is the reporter's explicit permission to open the link's content
for this report and only ever arrives in that field: the fragment of url is stripped
before storage.
A repeat report by the same reporter on the same link accumulates into the existing one and answers its id.
| url_token required | string Link URL token |
| url required | string [ 1 .. 2048 ] characters The share URL the reporter used. Its fragment, which may carry the link key, is stripped before storage. |
| description | string <= 4000 characters The reporter's own words about the abuse. |
| contact_email | string <= 320 characters Email for follow-up. Deleted together with the report once it is handled. |
| link_key | string <= 512 characters The link's share key, attached deliberately as permission to open the content for this report. Useful only on a link with a cloud datasource. |
Array of objects <= 4 items Evidence pieces, at most 4, at most 8 MiB decoded in total. |
{- "description": "string",
- "contact_email": "string",
- "link_key": "string",
- "attachments": [
- {
- "name": "string",
- "media_type": "image/png",
- "data": "string"
}
]
}{- "report_id": "0198a9e2-5b1a-7c3e-9f00-2a4b6c8d0e1f"
}Returns every ACL entry on the link: who holds which permissions. Entries carry no key
material — the caller's own wrapped share key comes from GET /links/{url_token}, and
nobody else's is ever returned. Requires link:read.
The ETag response header carries the link's ACL version; pass it as If-Match on
linksAclGrant and linksAclRevoke.
| url_token required | string Link URL token |
{- "items": [
- {
- "subject": "user:usr_a1b2c3d4e5f6g7h8i9j0",
- "scopes": [
- "link:manage"
]
}
]
}Grants permissions on a link to a subject: public, authenticated, one user or
agent, or a workspace's members. A user subject's optional encrypted_share_key goes
to the vault; workspace member keys travel separately via POST /vault/keys/batch.
Requires link:manage.
The first grant answers status: granted, an identical repeat status: unchanged, and
a repeat with different scopes 409 already_granted_different — revoke and grant
again to overwrite. If-Match with the ACL ETag answers 412 acl_version_mismatch
instead of racing a concurrent change.
| url_token required | string Link URL token |
| If-Match | string The link's ACL ETag as last seen by the client. When present and stale the call
answers 412 |
| subject required | string (SubjectSelector) <= 128 characters ACL subject selector — one string naming who a grant addresses:
|
| scopes required | Array of strings (LinkPermission) Items Enum: "link:read" "link:write" "link:manage" Access levels to grant. The server stores only the highest level requested. |
| encrypted_share_key | string <byte> <= 1024 characters The link's share key wrapped to the recipient's identity public key (base64).
Accepted only when |
| identity_public_key | string <byte> <= 64 characters The user recipient's identity public key |
{- "subject": "user:usr_a1b2c3d4e5f6g7h8i9j0",
- "scopes": [
- "link:read"
], - "encrypted_share_key": "string",
- "identity_public_key": "string"
}{- "status": "granted"
}Removes a subject's ACL entry from the link. Requires link:manage. Revoking a user
subject also deletes that recipient's wrapped share key; after revoking a workspace
subject, member keys that no longer have access are removed by a later sweep.
If-Match with the link's ACL ETag works as on linksAclGrant, answering 412
acl_version_mismatch when stale.
| url_token required | string Link URL token |
| If-Match | string The link's ACL ETag as last seen by the client. When present and stale the call
answers 412 |
| subject required | string (SubjectSelector) <= 128 characters ACL subject selector — one string naming who a grant addresses:
|
{- "subject": "user:usr_a1b2c3d4e5f6g7h8i9j0"
}{- "status": "ok"
}Opens an upload transfer session. The cloud datasource comes back with a presigned PUT URL per file named in the body.
A link without estimated_bytes gets no cloud datasource, and a cloud-only link answers
409 estimate_required. A declared size over the ceiling answers
403 size_limit_exceeded. The ceiling is estimated_bytes plus an allowance of 10 % or
512 KiB, whichever is larger, capped by the tier storage cap, and bounds the session's
per-path sum; re-presigning a path replaces its size.
A session idle for 5 minutes is abandoned; later calls answer 404 transfer_not_found,
and linksUploadUpdate keeps it alive.
| url_token required | string Link URL token |
Files to presign when the session opens; the cloud datasource then carries a PUT URL
per file in presigned_files. Omit the body to mint nothing up front and presign on
demand instead.
| presigned | boolean Default: false Ignored — presigned is the only access form the server issues. |
Array of objects (TransferFileRequest) Files to presign up front: a PUT each on upload, a GET each on download. Optional —
omit it and mint URLs on demand with |
{- "presigned": false,
- "files": [
- {
- "path": "docs/report.pdf",
- "size": 1048576,
- "if_match": "\"9b2cf535f27731c974343645a3985328\"",
- "if_none_match": true
}
]
}{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "datasources": [
- {
- "datasource_id": "ds_abc123def456",
- "size": {
- "estimated_bytes": 104857600,
- "actual_bytes": 98765432
}, - "agent_id": "agt_owner12345678901234567890ab"
}
]
}Reports upload progress on an open session and keeps it alive: repeating unchanged stats stops an idle session from being abandoned.
Reported bytes are held against the link's declared estimated_bytes plus an allowance
of 10 % or 512 KiB, whichever is larger; over that the call answers
403 size_limit_exceeded and the client must abort the transfer. A link that declared
no estimate is not checked.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID |
object (TransferStats) Aggregate transfer statistics | |
Array of objects (TransferDatasourceStat) Per-datasource transfer statistics | |
Array of objects (TransferFileStat) Per-file progress. Only clients using | |
object Additional transfer metadata (client-specific) |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}, - "datasources": [
- {
- "datasource_id": "ds_abc123",
- "bytes": 0
}
], - "files": [
- {
- "file_transfer_id": "trf_9K2pR4sEXAMPLE1234567890abcdef01",
- "bytes": 0,
- "status": "open"
}
], - "info": { }
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Mints fresh presigned PUT URLs for the named files on an open upload session, to renew
a URL that expired mid-transfer or to add files after the start. A path outside the
link's prefix — absolute, leading-slash, holding .., a backslash or a control byte, or
empty — answers invalid_request.
A file may carry if_match or if_none_match; the matching header then joins the
signature and comes back in PresignedFile.headers to be sent verbatim.
Issuance is as on linksUploadStart, and re-presigning a path replaces its previous
size, so renewing an expired URL is free.
| url_token required | string Link URL token |
required | Array of objects (TransferFileRequest) |
{- "files": [
- {
- "path": "docs/report.pdf",
- "size": 1048576,
- "if_match": "\"9b2cf535f27731c974343645a3985328\"",
- "if_none_match": true
}
]
}{- "files": [
- {
- "path": "docs/report.pdf",
- "headers": {
- "If-None-Match": "*"
}
}
]
}Starts a multipart upload for one large file and returns its upload_id. Mint part URLs
with /multipart/parts, then finish with /multipart/complete or /multipart/abort.
The path is validated under the link's prefix, else invalid_request.
Issuance is as on linksUploadPresign: a link without estimated_bytes answers
409 estimate_required, and a declared size over the ceiling answers
403 size_limit_exceeded. The size joins the session's per-path sum under the same
ceiling, and beginning the same path again replaces its size.
| url_token required | string Link URL token |
| path required | string File path relative to the link prefix; a path outside it is |
| size | integer <int64> >= 0 Declared total stored size, held to the same ceiling as a single-shot presign
(403 |
{- "path": "large/video.mp4",
- "size": 5368709120
}{- "path": "large/video.mp4",
- "upload_id": "2~aBc123EXAMPLEuploadId"
}Mints presigned UploadPart URLs for the named part numbers of an open multipart upload.
Callable repeatedly: for the first batch, to add parts when the count was not known up
front, or to renew a part URL that expired. An unknown upload_id answers
multipart_upload_not_found.
| url_token required | string Link URL token |
| path required | string |
| upload_id required | string The upload id from /multipart/begin. |
| part_numbers required | Array of integers 1-based part indices to presign (1..10000). |
{- "path": "large/video.mp4",
- "upload_id": "2~aBc123EXAMPLEuploadId",
- "part_numbers": [
- 1,
- 2,
- 3,
- 4
]
}{- "part_urls": [
- {
- "part_number": 1,
}
]
}Lists the parts already stored for one open multipart upload, so a resuming client can
skip them and complete with their ETags. An unknown upload_id answers
multipart_upload_not_found.
| url_token required | string Link URL token |
| path required | string |
| upload_id required | string The upload id from /multipart/begin. |
{- "path": "large/video.mp4",
- "upload_id": "2~aBc123EXAMPLEuploadId"
}{- "parts": [
- {
- "part_number": 1,
- "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
- "size_bytes": 5242880
}
]
}Lists the multipart uploads in progress under the link's prefix, for a client that lost
its upload_id: pick an entry, then call /multipart/list-parts to see what it already
holds. Unlike list-parts, which covers one upload, this covers the whole link.
| url_token required | string Link URL token |
{- "uploads": [
- {
- "path": "large/video.mp4",
- "upload_id": "2~aBc123EXAMPLEuploadId",
- "initiated_at": "2019-08-24T14:15:22Z"
}
]
}Completes a multipart upload from the (part_number, etag) list the client collected
while putting each part. An upload_id that is unknown, already completed or aborted
answers multipart_upload_not_found.
| url_token required | string Link URL token |
| path required | string File path relative to the link prefix (the one the parts were uploaded under). |
| upload_id required | string The upload id from /multipart/begin. |
required | Array of objects (MultipartCompletedPart) |
{- "path": "docs/report.pdf",
- "upload_id": "2~aBc123EXAMPLEuploadId",
- "parts": [
- {
- "part_number": 1,
- "etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
}
]
}{- "etag": "\"d41d8cd98f00b204e9800998ecf8427e-3\""
}Aborts a multipart upload and drops its staged parts. An upload_id that is unknown,
already completed or aborted answers multipart_upload_not_found, as on the other
multipart calls.
| url_token required | string Link URL token |
| path required | string File path relative to the link prefix. |
| upload_id required | string The upload id from /multipart/begin. |
{- "path": "docs/report.pdf",
- "upload_id": "2~aBc123EXAMPLEuploadId"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Deletes one object under the link's prefix. Presigned access carries no client-signed
delete, so removal goes through this call. Idempotent: deleting a missing key is a
no-op. The path is validated under the link's prefix, else invalid_request.
| url_token required | string Link URL token |
| path required | string File path relative to the link prefix. |
{- "path": "docs/report.pdf"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Lists the objects stored under the link's prefix, one page per call. The listing is flat
and recursive — every object under prefix is returned and the client groups them into
directories itself. Follow next_cursor to page to the end, or pass start_after to
begin from a known key. Each object carries its ETag and last-modified time.
| url_token required | string Link URL token |
| prefix | string Path prefix to list, relative to the link prefix. Omit or leave empty to list the whole link. |
| cursor | string Opaque continuation token from a prior response's |
| start_after | string Return only objects whose key, relative to the link prefix, sorts after this one
(exclusive). Applies to the first page only; later pages follow |
{- "prefix": "docs/",
- "cursor": "string",
- "start_after": "wal/wal-000000001234"
}{- "objects": [
- {
- "path": "docs/report.pdf",
- "size_bytes": 1048576,
- "etag": "\"9b2cf535f27731c974343645a3985328\"",
- "last_modified": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Opens a file-transfer inside an open upload session, giving the link owner per-file
visibility. A client that does not need it can use /upload/start, /upload/update and
/upload/end alone.
The caller must own the parent session; another actor's session answers 404 rather than
403. An Idempotency-Key header makes a retry safe: the same key returns the same
file_transfer_id. At most 128 file-transfers may be open in one session.
| url_token required | string Link URL token |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| transfer_id required | string Parent transfer session ID returned by |
| encrypted_metadata required | string <byte> <= 8192 characters Opaque base64 blob encrypted with the link's share key; the server never reads
inside it. Typically |
| estimated_bytes | integer <int64> Plaintext size hint. When present, |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "encrypted_metadata": "eyJwYXRoIjoicGhvdG9zL2ltZ18wMDEuanBnIn0=",
- "estimated_bytes": 0
}{- "file_transfer_id": "trf_9K2pR4sEXAMPLE1234567890abcdef01"
}Closes a file-transfer. Idempotent: a second call for the same file_transfer_id
answers 204 and publishes nothing. The caller must own the parent session; a
file-transfer that does not exist, belongs to another session or to another actor
answers 404.
When the file was opened with estimated_bytes, stats.bytes over that plus an
allowance of 10 % or 512 KiB, whichever is larger, answers 403 size_limit_exceeded;
the file stays open and the client must abort it.
| url_token required | string Link URL token |
| file_transfer_id required | string File-transfer ID returned by |
| success required | boolean Whether the file transfer completed successfully. |
| error | string <= 500 characters Error message if failed. The exact value |
object (TransferStats) Aggregate transfer statistics |
{- "success": true,
- "error": "string",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Ends an upload transfer session. On success: true the stored total in size.actual is
reconciled from storage rather than taken from the reported stats, and repeating the
call with identical stats changes nothing.
On success: false the client first aborts any multipart upload still in progress. Call
this endpoint even when the abort follows a server error. After a size_limit_exceeded
the client stops transferring, aborts its multipart uploads and then ends the session
with success: false.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID |
| success required | boolean Whether transfer completed successfully |
| error | string <= 500 characters Error message if failed |
object (TransferStats) Aggregate transfer statistics | |
Array of objects (TransferDatasourceStat) Per-datasource transfer statistics | |
object Additional transfer metadata (client-specific) |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "success": true,
- "error": "string",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}, - "datasources": [
- {
- "datasource_id": "ds_abc123",
- "bytes": 0
}
], - "info": { }
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Cancels an upload session in progress. It differs from /upload/end with
success: false in intent: abort records a deliberate cancellation, with the supplied
reason, as link.transfer_aborted. That frame reaches
the link owner and the aborting agent only, never the link topic.
Any file-transfers still open are closed, each with its own link.transfer_completed,
and the storage the session reserved is released.
The caller may abort only their own open session: another actor's session, an unknown
transfer id and a repeat abort all answer 404 transfer_not_found.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID returned by |
| reason | string <= 200 characters Free-form reason, carried in the |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "reason": "user cancelled from UI"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Opens a download transfer session and returns its transfer id and datasources; the cloud
datasource carries a presigned GET URL per file named in the body, and
linksDownloadPresign mints more or renews an expired one. Requires link:read.
A session idle for 5 minutes is abandoned (link.transfer_completed with
close_reason=abandoned) and later calls answer 404 transfer_not_found. A client that
holds a session open while only browsing keeps it alive with linksDownloadUpdate.
| url_token required | string Link URL token |
Files to presign when the session opens; the cloud datasource then carries a GET URL
per file in presigned_files. Omit the body to mint nothing up front and presign on
demand instead.
| presigned | boolean Default: false Ignored — presigned is the only access form the server issues. |
Array of objects (TransferFileRequest) Files to presign up front: a PUT each on upload, a GET each on download. Optional —
omit it and mint URLs on demand with |
{- "presigned": false,
- "files": [
- {
- "path": "docs/report.pdf",
- "size": 1048576,
- "if_match": "\"9b2cf535f27731c974343645a3985328\"",
- "if_none_match": true
}
]
}{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "datasources": [
- {
- "datasource_id": "ds_abc123def456",
- "size": {
- "estimated_bytes": 104857600,
- "actual_bytes": 98765432
}, - "agent_id": "agt_owner12345678901234567890ab"
}
]
}Reports download progress on an open session and keeps it alive: a client browsing a share without transferring repeats unchanged stats so the session is not abandoned.
Reported bytes are not held against the link's estimated_bytes, which is the
uploader's declaration and may lag what the datasources now hold.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID |
object (TransferStats) Aggregate transfer statistics | |
Array of objects (TransferDatasourceStat) Per-datasource transfer statistics | |
Array of objects (TransferFileStat) Per-file progress. Only clients using | |
object Additional transfer metadata (client-specific) |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}, - "datasources": [
- {
- "datasource_id": "ds_abc123",
- "bytes": 0
}
], - "files": [
- {
- "file_transfer_id": "trf_9K2pR4sEXAMPLE1234567890abcdef01",
- "bytes": 0,
- "status": "open"
}
], - "info": { }
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Mints fresh presigned GET URLs for the named files on an open download session, to renew
a URL that expired mid-transfer. Paths are validated under the link's prefix; an
absolute path, a leading slash, a .. segment, a backslash,
a control byte or an empty path answers invalid_request, as does if_match or
if_none_match, which a download presign does not accept.
| url_token required | string Link URL token |
required | Array of objects (TransferFileRequest) |
{- "files": [
- {
- "path": "docs/report.pdf",
- "size": 1048576,
- "if_match": "\"9b2cf535f27731c974343645a3985328\"",
- "if_none_match": true
}
]
}{- "files": [
- {
- "path": "docs/report.pdf",
- "headers": {
- "If-None-Match": "*"
}
}
]
}Opens a file-transfer inside an open download session, giving the link owner per-file
visibility. The first file-transfer of a session is the download itself: it moves the
link's download_count, and it is where the download cap of the owner's tier or the
owner's download_limit refuses with 409 link_download_limit_exceeded — a session
that only browses is never counted or refused.
The caller must own the parent session; another actor's session answers 404 rather than
403. An Idempotency-Key header makes a retry safe: the same key returns the same
file_transfer_id. At most 128 file-transfers may be open in one session.
| url_token required | string Link URL token |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| transfer_id required | string Parent transfer session ID returned by |
| encrypted_metadata required | string <byte> <= 8192 characters Opaque base64 blob encrypted with the link's share key; the server never reads
inside it. Typically |
| estimated_bytes | integer <int64> Plaintext size hint. When present, |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "encrypted_metadata": "eyJwYXRoIjoicGhvdG9zL2ltZ18wMDEuanBnIn0=",
- "estimated_bytes": 0
}{- "file_transfer_id": "trf_9K2pR4sEXAMPLE1234567890abcdef01"
}Closes a file-transfer. Idempotent: a second call for the same file_transfer_id
answers 204 and publishes nothing. The caller must own the parent session; a
file-transfer that does not exist, belongs to another session or to another actor
answers 404.
Reported bytes are not held against the file's estimated_bytes, which is metadata and
may lag the file's real size.
| url_token required | string Link URL token |
| file_transfer_id required | string File-transfer ID returned by |
| success required | boolean Whether the file transfer completed successfully. |
| error | string <= 500 characters Error message if failed. The exact value |
object (TransferStats) Aggregate transfer statistics |
{- "success": true,
- "error": "string",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}
}{- "code": "not_found",
- "status": "workspace_not_found",
- "error": "name is required",
- "details": {
- "retry_after_seconds": 30
}
}Ends a download transfer session.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID |
| success required | boolean Whether transfer completed successfully |
| error | string <= 500 characters Error message if failed |
object (TransferStats) Aggregate transfer statistics | |
Array of objects (TransferDatasourceStat) Per-datasource transfer statistics | |
object Additional transfer metadata (client-specific) |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "success": true,
- "error": "string",
- "stats": {
- "bytes_transferred": 0,
- "files_completed": 0,
- "files_total": 0,
- "dirs": 0,
- "symlinks": 0,
- "duration_ms": 0
}, - "datasources": [
- {
- "datasource_id": "ds_abc123",
- "bytes": 0
}
], - "info": { }
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Cancels a download session in progress, the call behind a UI cancel button and the
mirror of /upload/abort. Any caller holding link:read may abort their own download
session; a downloader need not own the link, and a download reserves no storage.
| url_token required | string Link URL token |
| transfer_id required | string Transfer session ID returned by |
| reason | string <= 200 characters Free-form reason, carried in the |
{- "transfer_id": "tr_4Fz4sL4iEXAMPLE",
- "reason": "user cancelled from UI"
}{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the agents currently taking part in a transfer session on this link, uploading
or downloading, so a client can show who is transferring without waiting for
link.transfer_started frames. The list is empty when no transfer is open, and it is
capped at 100 peers. A caller who cannot reach the link gets 404.
| url_token required | string Link URL token |
{- "peers": [
- {
- "agent_id": "agt_4Fz4sL4iEXAMPLE",
- "agent_name": "Bob's MacBook",
- "direction": "download"
}
]
}Peer-to-peer transfer between agents over WebRTC:
POST /auth/agents.p2p_storage: true.POST /links/{url_token}/upload/start or download/start.POST /agents/call; the peer receives call.incoming.upload/update
or download/update.upload/end or download/end.Opens a WebRTC signaling call to another agent and rings it. The caller passes the
callee's agent_id and the transfer_id of its own active transfer session, which
is what authorizes the call; the callee must be online.
The response carries the call_id, the STUN/TURN servers to put into the
RTCPeerConnection (TURN credentials are time-limited and specific to this call),
the relay URLs and the caller's relay jwt. The callee receives a call.incoming
frame with the same ICE servers and its own relay jwt. Both sides then exchange
SDP and ICE over the relay servers.
| callee_id required | string (AgentID) [ 8 .. 256 ] characters ^agt_[A-Za-z0-9_-]+$ Unique agent identifier, prefixed with |
| transfer_id required | string [ 32 .. 256 ] characters Active transfer session authorizing this call. A downloader gets it from
|
{- "callee_id": "agt_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
- "transfer_id": "tr_2p7mc0k2c2tj6f3q2v56y7wz9b1k4n6h"
}{- "call_id": "eis2heez-random-towiez1i",
- "ice_servers": [
- {
- "uri": "turn:relay.example.com?transport=udp",
- "username": "1735689600:abc123callid",
- "password": "dGhpcyBpcyBhIHNhbXBsZSBwYXNzd29yZA=="
}
], - "relays": [
- "wss://e.share.ninja/relay"
], - "jwt": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtleS0xIn0.eyJpc3MiOiJzaGFyZS5uaW5qYSIsInN1YiI6ImNhbGxfMTIzIiwiYXVkIjpbInJlbGF5LXNlbmRlciJdLCJzaWQiOiJhZ3RfY2FsbGVyIiwicmlkIjoiYWd0X2NhbGxlZSIsImV4cCI6MTcwMDAwMDAwMH0.signature"
}In-app inbox of the user: workspace invites, vault join requests, link share requests
and system alerts. Cursor-paginated, with mark-read and delete. A new entry is also
pushed as a notification.created frame.
Returns the signed-in user's own inbox, newest first, paginated by cursor.
Deleted notifications are never returned. Pass unread_only=true to keep only
the unread ones.
unread_count counts every unread notification of the user, not just this
page, so a badge needs no second call.
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
| unread_only | boolean Default: false Return only unread notifications. |
{- "items": [
- {
- "id": "ntf_abc123def456xyz7",
- "kind": "workspace.invite",
- "title": "Alice invited you to 'Marketing'",
- "body": "Click to accept the invite.",
- "payload": {
- "workspace_id": "ws_abc123def456xyz7",
- "invite_token": "inv_abc123def456xyz7"
}, - "triggered_by_agent_id": "agt_abc123def456xyz7",
- "read": false,
- "read_at": "2026-05-14T10:35:00Z",
- "created_at": "2026-05-14T10:30:00Z",
- "expires_at": "2026-08-12T10:30:00Z"
}
], - "pagination": {
- "next_cursor": "g2wAAAABBm5leHQ",
- "previous_cursor": "g2wAAAABBXByZXY",
- "has_more": false,
- "total_count": 150
}, - "unread_count": 3
}Removes one of the caller's own notifications from the inbox; later listings no longer return it. A notification that belongs to someone else answers the same 404 as a missing one.
| notification_id required | string Public ID of the notification. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Marks one of the caller's own notifications read and answers 204. Calling it
again changes nothing and keeps the original read_at. A notification that
belongs to someone else answers the same 404 as a missing one.
| notification_id required | string Public ID of the notification. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Returns the audit events visible to the authenticated user: events where the user is
the actor or the subject, and events from workspaces the user is a member of. Results
are sorted by created_at descending and paged with cursor (sequential) or offset
(page-number UIs).
date_from and date_to are inclusive. A span wider than 90 days answers 400
invalid_date_range; with neither bound the feed starts 30 days back. Filtering by a
workspace the caller is not a member of answers 403.
| workspace_id | string Example: workspace_id=ws_a1b2c3d4e5f6g7h8 Public ID of a workspace to restrict results to. The caller must be a member. |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
| action | string Example: action=link.created Exact action to match, such as |
| action_prefix | string <= 64 characters ^[a-z_]+(\.[a-z_]+)*\.$ Example: action_prefix=link. Only actions starting with this prefix. The prefix ends on a segment boundary:
|
| session_id | string Example: session_id=tr_abc123def456xyz7 Restrict results to one transfer session. Not served yet: any value answers 400
|
| result | string Enum: "success" "failure" "denied" Filter by event outcome. |
| date_from | string <date-time> Example: date_from=2026-01-01T00:00:00Z Inclusive lower bound on |
| date_to | string <date-time> Example: date_to=2026-12-31T23:59:59Z Inclusive upper bound on |
{- "items": [
- {
- "id": "fN05wJugEfIG4qgEti5KYQ",
- "created_at": "2025-01-15T10:30:00Z",
- "ip": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
- "actor_user_id": "usr_abc123def456xyz7",
- "actor_agent_id": "agt_abc123def456xyz7",
- "actor_user_name": "Alice",
- "actor_agent_name": "MacBook Pro",
- "subject_type": "session",
- "subject_id": "ses_abc123def456xyz7",
- "workspace_id": "ws_abc123def456xyz7",
- "action": "link.created",
- "resource": "session",
- "result": "success",
- "details": {
- "type": "reason",
- "reason": "string"
}, - "severity": "critical",
- "actor_workspace_id": "string",
- "req_id": "string",
- "subject_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}, - "resource_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}
}
], - "page_info": {
- "next_cursor": "g2wAAAABBm5leHQ",
- "previous_cursor": "g2wAAAABBXByZXY",
- "has_more": false,
- "total_count": 150
}
}Returns the audit events of one app. Requires the app:manage scope on it; a caller
without access to the app is answered 404. Pass current as app_id to read the app
of the current context. Paged with cursor from page_info.next_cursor, or with
offset for page-number UIs.
| app_id required | string Example: current App public ID, or |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "id": "fN05wJugEfIG4qgEti5KYQ",
- "created_at": "2025-01-15T10:30:00Z",
- "ip": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
- "actor_user_id": "usr_abc123def456xyz7",
- "actor_agent_id": "agt_abc123def456xyz7",
- "actor_user_name": "Alice",
- "actor_agent_name": "MacBook Pro",
- "subject_type": "session",
- "subject_id": "ses_abc123def456xyz7",
- "workspace_id": "ws_abc123def456xyz7",
- "action": "link.created",
- "resource": "session",
- "result": "success",
- "details": {
- "type": "reason",
- "reason": "string"
}, - "severity": "critical",
- "actor_workspace_id": "string",
- "req_id": "string",
- "subject_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}, - "resource_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}
}
], - "page_info": {
- "next_cursor": "g2wAAAABBm5leHQ",
- "previous_cursor": "g2wAAAABBXByZXY",
- "has_more": false,
- "total_count": 150
}
}Returns the audit events of the app of the current context. Requires the app:manage
scope. Paged with cursor (sequential) or offset (page-number UIs).
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "id": "fN05wJugEfIG4qgEti5KYQ",
- "created_at": "2025-01-15T10:30:00Z",
- "ip": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
- "actor_user_id": "usr_abc123def456xyz7",
- "actor_agent_id": "agt_abc123def456xyz7",
- "actor_user_name": "Alice",
- "actor_agent_name": "MacBook Pro",
- "subject_type": "session",
- "subject_id": "ses_abc123def456xyz7",
- "workspace_id": "ws_abc123def456xyz7",
- "action": "link.created",
- "resource": "session",
- "result": "success",
- "details": {
- "type": "reason",
- "reason": "string"
}, - "severity": "critical",
- "actor_workspace_id": "string",
- "req_id": "string",
- "subject_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}, - "resource_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}
}
], - "page_info": {
- "next_cursor": "g2wAAAABBm5leHQ",
- "previous_cursor": "g2wAAAABBXByZXY",
- "has_more": false,
- "total_count": 150
}
}Returns the audit events of one workspace: settings changes, members added and removed,
invites created and revoked, and permission changes. Requires the workspace:write
scope on that workspace. Paged with cursor (sequential) or offset (page-number UIs).
| workspace_id required | string Example: ws_a1b2c3d4e5f6g7h8 Workspace public ID. |
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "id": "fN05wJugEfIG4qgEti5KYQ",
- "created_at": "2025-01-15T10:30:00Z",
- "ip": "192.168.1.100",
- "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
- "actor_user_id": "usr_abc123def456xyz7",
- "actor_agent_id": "agt_abc123def456xyz7",
- "actor_user_name": "Alice",
- "actor_agent_name": "MacBook Pro",
- "subject_type": "session",
- "subject_id": "ses_abc123def456xyz7",
- "workspace_id": "ws_abc123def456xyz7",
- "action": "link.created",
- "resource": "session",
- "result": "success",
- "details": {
- "type": "reason",
- "reason": "string"
}, - "severity": "critical",
- "actor_workspace_id": "string",
- "req_id": "string",
- "subject_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}, - "resource_details": {
- "kind": "link",
- "url_token": "string",
- "name": "string",
- "encrypted_metadata": "string",
- "encrypted_share_key": "string"
}
}
], - "page_info": {
- "next_cursor": "g2wAAAABBm5leHQ",
- "previous_cursor": "g2wAAAABBXByZXY",
- "has_more": false,
- "total_count": 150
}
}Lists the caller's live (not revoked) API keys, oldest first, cursor-paged. The
secret is never shown again — prefix is how a key is told apart; last_used_at
is approximate.
| cursor | string Example: cursor=g2wAAAABBmN1cnNvcg Cursor returned by a previous response to continue listing results |
| offset | integer >= 0 Example: offset=950 Absolute number of items to skip from the start of the list, for page-number
UIs: |
| limit | integer [ 1 .. 100 ] Default: 50 Example: limit=50 Maximum number of items to return for this request (default 50) |
{- "items": [
- {
- "key_id": "key_018f7b2e-3c4d-7a1b-9e6f-2a5c8d1e4b7a",
- "name": "CI pipeline",
- "prefix": "p7aQG",
- "scopes": [
- "workspace:read",
- "workspace:write"
], - "created_at": "2024-12-01T07:34:21Z",
- "last_used_at": "2025-05-01T06:15:02Z"
}, - {
- "key_id": "key_018f7b2e-4d5e-7b2c-8f7a-3b6d9e2f5c8b",
- "name": "Data export",
- "prefix": "T3kn",
- "scopes": [
- "workspace:read"
], - "created_at": "2025-02-20T18:00:00Z",
- "last_used_at": null
}
], - "page_info": {
- "next_cursor": "g2wAAAABBmFwaUtleTI",
- "previous_cursor": null,
- "has_more": true,
- "total_count": 5
}
}Creates an API key for programmatic access. The token is returned once, here, and
never again; prefix is the visible head listings show. Send it as
Authorization: Bearer <token>. A key acts as the user with no device session
behind it, so sessions and factors are out of its reach.
scopes narrows the key to a subset of the user's content permissions, named as
the application's area:level scopes; an unknown name is invalid_scopes, and omitted or
empty leaves the key the user's full rights. expires_at in the past is
invalid_expires_at; past it the key stops authenticating.
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
| name required | string [ 1 .. 255 ] characters Label shown in the key listing. |
| scopes | Array of strings Permissions to limit the key to. Omitted or empty gives it the user's full rights. |
| expires_at | string or null <date-time> When the key stops authenticating. Null for a key that does not expire. |
{- "name": "Automation bot",
- "scopes": [
- "workspace:read",
- "workspace:write"
], - "expires_at": "2025-12-01T00:00:00Z"
}{- "key_id": "key_018f7b2e-5e6f-7c3d-9a8b-4c7e1f3a6d9c",
- "name": "Automation bot",
- "prefix": "c6n9rQ2e",
- "scopes": [
- "workspace:read",
- "workspace:write"
], - "created_at": "2025-05-20T09:10:00Z",
- "last_used_at": null,
- "token": "c6n9rQ2e1kA7p0s3Zt8vWq4Lm7Xy1Bn0",
- "expires_at": "2025-12-01T00:00:00Z"
}Revokes one of the caller's own keys: it stops authenticating at once (401 from then
on) and leaves the listing. Irreversible. A key that is not the caller's, or does
not exist, is api_key_not_found.
| key_id required | string Example: key_018f7b2e-3c4d-7a1b-9e6f-2a5c8d1e4b7a Identifier of the API key to revoke |
| Idempotency-Key | string <= 255 characters Example: 1d24c9e0-3f1b-4a75-b0d9-6d3b2d9c1a55 Optional key that makes a mutating request retry-safe: a repeat carrying the same key replays the original outcome instead of creating a second one, for as long as the original operation is on record. |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Server-to-client event stream. Browsers connect with the one-time ticket from
POST /auth/agents (GET /ws/connect?ticket=…; 30 s TTL, single use, bound to the
agent), native clients with a Bearer token (GET /ws/action). Frames are WSMessage
variants; subscriptions are managed over /events/subscriptions.
Upgrades to the server-to-client WebSocket using the one-time ticket from
POST /auth/agents (30 s TTL, bound to the agent). The server streams WSMessage
frames; the client sends nothing. Subscriptions are managed over
/events/subscriptions and survive reconnects; the caller's own agent:<agent_id>
inbox is always delivered.
Pass last_seen_id, the id of the last frame processed, to receive the frames
missed while offline before the live stream continues; past the retention window
re-read the resource over REST. A subscription.revoked frame means the caller lost
that topic.
| ticket required | string Example: ticket=dGhpcyBpcyBhIHRlc3QgdGlja2V0IGZvciBkZW1v One-time ticket from |
| last_seen_id | string Example: last_seen_id=evt_0192b1e0-7c5a-7f4a-9c1e-3f1e2d4c5b6a The |
{- "type": "subscription.revoked",
- "id": "evt_019ead06-e190-7e8a-8ff5-1d75f0b36e58",
- "topic": "link:k7mstq3w",
- "timestamp": "2025-01-15T10:30:00.123Z"
}Upgrades to the same server-to-client stream as GET /ws/connect, authenticated by
Authorization: Bearer <access token> instead of a ticket. For native and CLI clients,
which can set headers on the upgrade request; browsers use the ticket flow.
last_seen_id works the same way.
| last_seen_id | string Example: last_seen_id=evt_0192b1e0-7c5a-7f4a-9c1e-3f1e2d4c5b6a The |
{- "code": "bad_request",
- "status": "invalid_request",
- "error": null
}Subscription billing of a Personal workspace: plans, checkout, the current subscription and its entitlements. Payment-provider identifiers never appear in the contract.
GET /billing/plans.POST /workspaces/{workspace_id}/billing/checkout and an
Idempotency-Key header; open the returned checkout_url when present.GET /workspaces/{workspace_id}/billing.Returns the published Personal offer versions, each with its price and the entitlements it grants. Open to anyone; no session is needed. A deployment that serves no billing catalog answers an empty list, and billing switched off answers 503.
{- "plans": [
- {
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "plan_id": "sharon-personal-pro",
- "display_name": "Pro",
- "tier": "pro",
- "interval": "month",
- "amount_minor": 999,
- "currency": "USD",
- "entitlements": {
- "tier": "pro",
- "storage_bytes": 2199023255552,
- "retention_seconds": 172800
}
}
]
}Returns the entitlements in force for the workspace and, when one exists, its subscription. Only the workspace owner may call it; a workspace the caller does not own answers 404 rather than 403.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
{- "workspace_id": "ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53",
- "entitlements": {
- "tier": "pro",
- "storage_bytes": 2199023255552,
- "retention_seconds": 172800
}, - "subscription": {
- "id": "bsub_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "state": "active",
- "period_start": "2019-08-24T14:15:22Z",
- "period_end": "2019-08-24T14:15:22Z",
- "cancel_at_period_end": false,
- "pinned_currency": "EUR",
- "pinned_amount_minor": 530,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
}Creates a checkout operation for one published offer version and returns it.
The workspace owner calls it. ui_mode picks the payment form: hosted, the
default, answers with a checkout_url to send the buyer to, embedded with a
client_secret and publishable_key. Redirect URLs are chosen by the server,
never taken from the request.
The same Idempotency-Key with the same body replays the original operation.
409 answers a reused key with a different body, a workspace that already has an
active subscription, a payment still settling, or an unresolved earlier
operation.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
| Idempotency-Key required | string [ 1 .. 255 ] characters .*\S.* Key that makes this billing mutation retry-safe; the same key with the same body replays the original operation |
| offer_version_id required | string [ 1 .. 255 ] characters Published offer version to subscribe to |
| ui_mode | string Enum: "hosted" "embedded" How the payment form is shown; absent means |
{- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "ui_mode": "hosted"
}{- "id": "bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "kind": "checkout",
- "state": "succeeded",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "client_secret": "string",
- "publishable_key": "string",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns one checkout, plan-change, cancellation or reactivation operation of
the workspace, with its current state. An operation that belongs to another
workspace answers 404.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
| operation_id required | string^bop_[A-Za-z0-9_-]+$ Example: bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63 Billing operation public ID |
{- "id": "bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "kind": "checkout",
- "state": "succeeded",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "client_secret": "string",
- "publishable_key": "string",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Moves the active subscription to another published offer version at once, with
a prorated charge or credit, and returns the operation. The workspace owner
calls it. The same Idempotency-Key with the same body replays the original
operation; the same key with a different body answers 409, as does an earlier
operation that is still unresolved.
A lower quota takes effect once the plan change is confirmed; a higher quota takes effect only after the prorated charge succeeds.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
| Idempotency-Key required | string [ 1 .. 255 ] characters .*\S.* Key that makes this billing mutation retry-safe; the same key with the same body replays the original operation |
| offer_version_id required | string [ 1 .. 255 ] characters Published offer version to switch to |
{- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1"
}{- "id": "bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "kind": "checkout",
- "state": "succeeded",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "client_secret": "string",
- "publishable_key": "string",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Schedules the active subscription to end after its current paid period and
returns the operation; the subscription's cancel_at_period_end turns true.
There is no immediate cancellation. The same Idempotency-Key with the same
body replays the original operation.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
| Idempotency-Key required | string [ 1 .. 255 ] characters .*\S.* Key that makes this billing mutation retry-safe; the same key with the same body replays the original operation |
{- "id": "bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "kind": "checkout",
- "state": "succeeded",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "client_secret": "string",
- "publishable_key": "string",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Removes a scheduled period-end cancellation while the subscription is still
running, and returns the operation. The same Idempotency-Key with the same
body replays the original operation. A subscription that has no cancellation
scheduled answers 404.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
| Idempotency-Key required | string [ 1 .. 255 ] characters .*\S.* Key that makes this billing mutation retry-safe; the same key with the same body replays the original operation |
{- "id": "bop_0198a6d7-9b46-7d5c-a501-d75e8b276c63",
- "kind": "checkout",
- "state": "succeeded",
- "offer_version_id": "ofv_sharon-personal-pro-monthly-v1",
- "client_secret": "string",
- "publishable_key": "string",
- "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Returns a short-lived, already-authenticated URL where the workspace owner updates the payment method of the active subscription. The caller needs a signed-in user session. The URL is handed out once and is neither stored nor logged. A workspace with no live subscription answers 404.
| workspace_id required | string^ws_[A-Za-z0-9_-]+$ Example: ws_6f16c25a-84d9-4ccf-bc6e-5333c6456f53 Personal workspace public ID |
{
}Reports service liveness and the running version. Public, no authentication. The check is liveness only: it does not probe the database or any downstream dependency, so a 200 means the process is serving, not that every subsystem is healthy.
{- "status": "ok",
- "version": "1.0.0"
}