Skip to content

IPC Endpoints ​

Reference for Flowsta Vault's local IPC API.

Flowsta Vault runs an HTTP server on 127.0.0.1:27777 that apps use for identity linking, authentication, backups, and document signing. The @flowsta/holochain SDK wraps these endpoints, but you can call them directly if needed. Every signature happens on the user's device with keys that never leave it - no one in between.

This page describes Vault 1.5.0. Where a field or behavior arrived in a later version than the endpoint, the version is noted; older Vaults omit the field or answer 404 for the route.

Base URL ​

http://127.0.0.1:27777

Vault tries ports 27777, 27778, 27779 in sequence if the previous port is in use.

One Vault per computer user account (Vault 1.5.0+)

The Vault answers only processes of the computer user account (the operating-system account) it runs in. On a computer with several user accounts, each account can run its own Vault, and those Vaults share the three ports: probe all three and use the one that answers. A Vault running in another OS account refuses every request with 403 other_user and tells the caller nothing else - not even the identity header. "Account" on this page always means that OS account; the thing inside the Vault is an identity.

Requests are JSON. Every POST needs Content-Type: application/json. The global request body cap is 8 MB; POST /backup accepts up to 55 MB. A body over the cap is refused with 413 body_too_large.

Status ​

GET /status ​

Check if Vault is running and unlocked.

Response:

json
{
  "unlocked": true,
  "initialized": true,
  "agent_pub_key": "uhCAk7JpEWfkiV...",
  "did": "did:flowsta:uhCAk...",
  "active_identity": "uhCAk7JpEWfkiV...",
  "identity_epoch": 3,
  "instance_id": "ccd9e0e134fee420",
  "claims": [],
  "version": "1.5.0",
  "display_name": "John Doe",
  "web_username": "johndoe",
  "profile_picture": "data:image/svg+xml;base64,...",
  "web_email": null
}

TIP

If the request fails to connect, Vault is not running. The running field is not part of the response - a successful response means Vault is running.

Scope enforcement

Profile fields (display_name, web_username, profile_picture) are only returned if your app was granted those scopes by the user at link time - the username scope gates the web_username field. Fields for ungranted scopes are returned as null. Configure which scopes your app requests in your app settings at dev.flowsta.com.

The agent_pub_key and did fields are always present when the Vault is unlocked - they are not scope-gated.

web_email is first-party only: it is always in the JSON and always null for your app. It is pre-fill for Flowsta's own consent page; apps receive an email only through the email grant below.

Email (Vault 1.3.0+)

email and email_verified appear only for a linked app the user has allowed to receive their email in a Vault dialog - the sign-in dialog when your app requests the email scope on /authenticate, or the relay approval screen. The email scope on its own is not enough, and a remembered site does not imply it: the first request for email always shows the user the address that will be shared. Only a verified address is ever returned, so email_verified is always true when email is present. The same grant is filed with Flowsta, so /oauth/userinfo returns the same address for OAuth apps. Both fields are omitted (not null) when there is no grant.

json
{ "email": "user@example.com", "email_verified": true }

Identity fields (Vault 1.5.0+)

A Vault can hold more than one identity, with one open at a time.

  • active_identity - the agent key of the identity the Vault holds, whether unlocked or locked; null before any identity is set up. agent_pub_key is only present while unlocked.
  • identity_epoch - counts every identity change on this device and never repeats a past value. If it moved since you last looked, the identity may have changed, even if it has changed back since.
  • instance_id - identifies the running Vault process; it changes when the Vault restarts.
  • claims - nonces the Vault received through a flowsta://claim/v1?nonce=<hex> link in the last two minutes: 8-64 hex characters each, reported lower-cased, at most the 8 most recent. The operating system delivers that link to the current user's Vault only, so a page that opens it and then finds its nonce in a Vault's claims knows that Vault is this user's.
  • initialized - false on a Vault with no identity set up yet.

Older Vaults omit these fields.

Pinning the identity (Vault 1.5.0+)

An app bound to one identity can send expected_identity (that identity's agent key) with any request: in the JSON body of a POST, or in the query string of a GET. A Vault holding a different identity refuses with 409 identity_mismatch, and one with no identity with 409 identity_unconfirmed, so a request never acts for the wrong person. The check runs again after any unlock or approval wait.

While the Vault is unlocked, every response carries the header x-flowsta-vault-identity with the agent key of the unlocked identity. A locked Vault sends no header - read active_identity from /status instead - and an other_user refusal never carries it.

:::caution Desktop apps: send an Origin header Vault resolves scope grants and session approvals by the Origin request header. Browsers send it automatically, but native HTTP clients (Rust reqwest, Go, Python, …) do not - and without it, scope-gated /status fields stay null and approvals won't stick.

Pick one stable, app-specific origin (for example yourapp://app) and send it on every Vault request - /link-identity, /authenticate, and /status must all use the same value.

rust
client.post(format!("http://127.0.0.1:{port}/authenticate"))
    .header("Origin", "yourapp://app")
    // ...

:::

Agent Linking ​

POST /link-identity ​

Request an identity link. Shows an approval dialog in Vault (60-second timeout, 408 timeout after that). Needs an unlocked Vault (403 vault_locked).

Request:

json
{
  "app_name": "ChessChain",
  "client_id": "flowsta_app_abc123",
  "app_agent_pub_key": "uhCAk..."
}

Before the dialog the Vault validates the request: an app_agent_pub_key that does not decode as a uhCAk... key is 400 invalid_agent_pub_key, and an empty client_id is 400 missing_client_id. The Vault then verifies your client_id with Flowsta. The first link needs internet: a client_id Flowsta does not recognize or has disabled is refused with 403 and Flowsta's own code (app_not_found, app_disabled, invalid_client_id); if Flowsta cannot be reached and the Vault has never verified this client_id before, the answer is 503 api_unreachable. Later links can use the cached registration, so re-linking works offline. The dialog shows the app name, organization, and scopes from your registration, not from the request.

Response (on approval):

json
{
  "success": true,
  "vault_agent_pub_key": "uhCAk...",
  "vault_signature": "base64-encoded-signature"
}

Response (on denial): HTTP 403 with the standard error envelope:

json
{
  "error": "user_denied",
  "description": "User rejected the identity link request."
}

An approved link also approves this origin for /authenticate in this session, and opens a 120-second window in which a POST /sign from the same origin (the agent-pair signature of the linking ceremony) proceeds without a second dialog.

One app agent, one identity (Vault 1.5.0+): if the same app_agent_pub_key is already linked to another identity in this Vault, the request is refused with HTTP 409 agent_linked_elsewhere, and the description names that identity. Open the app as that identity, or unlink it there first. Many app agents may link to one identity.

POST /revoke-identity ​

Notify Vault that an identity link has been revoked. No approval dialog.

Who may call it: a Flowsta page (https://*.flowsta.com) may revoke any link. Any other origin must be a linked app revoking its own link: its origin must resolve to a linked client_id, and the app_agent_pub_key it names must belong to that app's link record. Every other caller gets 403 forbidden - otherwise any local page could unlink the user's apps.

Request:

json
{
  "app_name": "ChessChain",
  "app_agent_pub_key": "uhCAk..."
}

Response: { "success": true }

Check if a specific agent is still linked. No link or unlock required.

Query parameters: app_agent_pub_key (the key looked up), client_id (accepted, currently unused)

GET /link-status?client_id=flowsta_app_abc123&app_agent_pub_key=uhCAk...

Response:

json
{
  "linked": true,
  "app_name": "ChessChain"
}

When the agent is not linked the response is { "linked": false } - app_name is omitted.

Authentication (Desktop Apps) ​

POST /authenticate ​

Authenticate a desktop app. Shows an approval dialog in Vault with a 60-second timeout (408 timeout after that). If the Vault is locked when the request arrives, it brings its unlock screen forward and holds the request for about 55 seconds; an unlock inside that window carries the same request straight on to approval, otherwise the response is 403 vault_locked.

Approval model: if the user approved a /link-identity request from the same origin, /authenticate is approved automatically - so the standard first-run sequence (link, then authenticate) shows one dialog, not two. A site the user chose to remember in the dialog stays approved across locking the Vault and across restarts, until the user revokes it in the Vault's Connections page (Vault 1.3.0+; earlier Vaults forgot remembered sites at every lock). One exception: the first time an app requests the email scope, the dialog is shown even for a remembered site, because something new is being shared.

Request:

json
{
  "app_name": "Your Desktop App",
  "client_id": "flowsta_app_abc123",
  "challenge": "base64-challenge-to-sign",
  "reason": "Login to Your Desktop App",
  "scopes": ["email"]
}

The client_id, challenge and scopes fields are optional. The reason field is shown in the approval dialog.

challenge: base64 (standard alphabet) of the bytes to sign; anything else is 400 invalid_challenge. Decoded bytes that start with flowsta- are refused with 400 reserved_prefix - that prefix belongs to Flowsta's own sign-in protocols, and signing it from a generic dialog would let an app mint a real Flowsta session. The one carve-out is the web login challenge (flowsta-auth-challenge:v1:..., issued by POST /auth/vault/challenge), which the Vault signs only for an https://*.flowsta.com origin. Third-party apps therefore cannot have a /auth/vault/challenge string signed: issue your own random challenge from your backend, have the Vault sign it here, and verify the Ed25519 signature against agent_pub_key yourself.

scopes (Vault 1.3.0+): requires client_id. Scopes are checked against your app's registration at dev.flowsta.com; anything your app is not registered for is ignored. Today email is the scope that changes the dialog: it shows the user the address that will be shared and states that your app receives it as text it can keep. If they allow, the address comes back in the response. The grant is recorded (so later sign-ins auto-approve as usual) and filed with Flowsta under a device signature only when your origin is bound to that client_id - a Flowsta page, or an origin linked to it through /link-identity; an unlinked origin gets the address for this sign-in only and is asked again next time. An unverified email is never offered - the dialog tells the user why - and the response then carries no email.

Response:

json
{
  "success": true,
  "did": "did:flowsta:uhCAk...",
  "agent_pub_key": "uhCAk...",
  "signature": "base64-encoded-ed25519-signature",
  "signed_at": "2026-07-07T18:30:00Z",
  "email": "user@example.com",
  "email_verified": true
}

signed_at is an ISO-8601 string and is always present. Only signature is conditional - it is null unless a challenge was provided. email and email_verified are present only when email was requested and allowed.

Document Signing (Sign It) ​

POST /sign-document ​

Sign a file hash with the user's identity key - the Sign It endpoint. Shows an approval dialog in Vault with the app name, file label, and hash. The SDK wraps this as signDocument().

Who may call it: any origin may request a signature - the user approves each one in the dialog, and the dialog names the app (for a linked app, the name from its registration; otherwise app_name or the origin). Publishing (commit: true) is limited to Flowsta pages and linked apps; any other origin asking to publish gets 403 tier_forbidden before the dialog.

Request:

json
{
  "file_hash": "a7f3b9c1e2d4...",
  "label": "Illustration.png",
  "intent": "authorship",
  "ai_generation": "none",
  "content_rights": { "license": "cc-by", "ai_training": "not_allowed" },
  "app_name": "ArtStudio",
  "client_id": "flowsta_app_abc123",
  "commit": true
}
FieldRequiredDescription
file_hashYesSHA-256 of the file as a 64-character hex string (400 invalid_file_hash otherwise). A hash whose bytes start with flowsta- is refused with 403 reserved_prefix
labelNoHuman-readable name shown in the approval dialog
intentNoWhy the file is signed: authorship, approval, witness, receipt, agreement
ai_generationNoAI disclosure: none, assisted, generated
content_rightsNoContent rights manifest (license, commercial_licensing, ai_training, contact_preference)
app_nameNoShown in the approval dialog (falls back to your origin). A linked app's registered name wins over this field
client_idNoYour developer client_id - enables usage tracking and sponsored signing. For a linked app the linked client_id is used regardless
commentNoSigner note stored with a published signature (280 characters max, 400 invalid_comment)
perceptual_hashNoImage/audio/video fingerprint for fuzzy matching on the verify page
thumbnailNoSmall preview image (data:image/... URI, under 300 KB, 400 invalid_thumbnail) committed after a published signature, in the background
supersedesNoHex action hash (39 bytes) of an earlier signature this one amends (400 invalid_supersedes)
commitNoPublish the signature to the Sign It network from this device. Flowsta pages and linked apps; any other origin gets 403 tier_forbidden. Draws on the signing quota (403 quota_exceeded)
jobNoRun as an async job: respond immediately with a job_id

Response:

json
{
  "success": true,
  "file_hash": "a7f3b9c1e2d4...",
  "signature": "base64-encoded-ed25519-signature",
  "agent_pub_key": "uhCAk...",
  "signed_at": "2026-07-07T18:30:00Z",
  "action_hash": "84202484..."
}

action_hash is set when the signature was published to the Sign It network (commit: true) - it's the hex action hash of the committed record. Otherwise it is null and your app receives the raw signature to use as it sees fit. A publish that was requested but could not happen is an error (503 conductor_not_ready or 500 commit_failed), never a silent signature-only response.

Timing. A locked Vault answers third-party callers 403 vault_locked at once. For a Flowsta page the Vault instead brings its unlock screen forward and holds the request (60 s, or 180 s as a job); an unlock inside that window carries the request on to approval. When publishing, the Vault checks the quota and waits for its conductor before asking for approval (up to 90 s, or 300 s as a job; 503 conductor_not_ready after that), so an approval can always land. The approval dialog itself waits 60 s (120 s as a job) and then answers 408 timeout.

Async job mode: with job: true the endpoint validates and immediately returns { "job_id": "sign-..." }. Poll GET /op-status/:job_id for progress; the request rides through unlock, conductor startup, and the approval dialog without holding a connection open. Identical double-submits join the in-flight job instead of signing twice.

If the request carries a client_id whose organization sponsors signing, a published signature draws from the organization's signing pool instead of the user's personal quota - and the approval dialog tells the user exactly that. If the sponsor pool for the period is used up, the signature falls back to the user's personal quota, and the dialog labels that honestly too. When both are dry, the request fails with quota_exceeded and a description explaining the state.

Signature & Profile Management ​

These endpoints let the flowsta.com dashboard operate on the user's own device-held records. They are restricted to Flowsta origins (403 tier_forbidden otherwise) and each mutation passes a per-action approval dialog in Vault. All of them accept job: true for async job mode and share the timing of /sign-document: a locked Vault holds the request through the unlock, the conductor is awaited before the dialog, and the dialog answers 408 timeout when the user does not respond.

POST /profile-update ​

Update the display name and/or profile picture stored in the user's own cell.

Request: { "display_name": "New Name", "profile_picture": "data:image/...", "job": false } - at least one of the two fields, else 400 nothing_to_update. display_name is trimmed and must be 80 characters or fewer (400 invalid_display_name); profile_picture must be a data:image/... URI or an https:// URL under 800 KB (400 invalid_profile_picture). Response: { "success": true }.

POST /revoke-signature ​

Revoke a published signature.

Request: { "action_hash": "<hex 39-byte action hash>", "reason": "optional, 280 chars max" } (400 invalid_action_hash, 400 invalid_reason). Response: { "success": true, "revocation_hash": "..." }. A failed write is 500 revoke_failed.

POST /set-thumbnail ​

Attach a preview image to a published signature.

Request: { "action_hash": "<hex 39-byte action hash>", "thumbnail": "data:image/... (under 300 KB)" } (400 invalid_action_hash, 400 invalid_thumbnail). Response: { "success": true, "thumbnail_hash": "..." }. A failed write is 500 write_failed.

GET /op-status/:job_id ​

Poll an async job started with job: true.

Response:

json
{
  "job_id": "sign-1774321234-a1b2c3d4",
  "stage": "awaiting_approval"
}

job_id is <op>-<unix time>-<hex> with op one of sign, profile, revoke, thumbnail. stage is one of waiting_unlock, preparing, awaiting_approval, publishing, done, failed. A fresh job starts at preparing and can move to waiting_unlock when the Vault turns out to be locked, so do not expect a strict order - treat every stage but done and failed as "still working". When done, the response includes result (the operation's normal response body); when failed, it includes error and description. Jobs expire 10 minutes after their last update (404 unknown_job).

GET /signatures ​

Read the user's signatures from the Vault - the source of truth for their own records. Flowsta origins only; read-only, so no approval dialog. A locked Vault answers 403 vault_locked without raising its unlock screen (a background read must not interrupt the user); a conductor not ready within 5 seconds answers 503 conductor_not_ready.

Response: { "source": "vault", "signatures": [...] } - the user's own signatures plus linked-agent history.

GET /connections ​

Read the registry of identity-linked apps and trusted origins. Flowsta origins only; read-only. A locked Vault answers 403 vault_locked.

Response:

json
{
  "apps": [
    {
      "app_name": "ChessChain",
      "app_agent_pub_key": "uhCAk...",
      "linked_at": 1774321234,
      "client_id": "flowsta_app_abc123",
      "scopes": ["openid", "display_name"]
    }
  ],
  "trusted_origins": [{ "origin": "yourapp://app", "first_seen": 1774321234 }]
}

first_seen is null for an origin the Vault remembers but has no first-contact record for.

Backups ​

Every backup route except GET /backup/limits requires a linked origin (403 not_linked) and a client_id that matches the one linked to that origin (403 client_id_mismatch) - an app never reads, writes, or deletes another app's backups. Writing and retrieving work while the Vault is locked, as long as it has been unlocked once this session (403 vault_never_unlocked before that); listing and deleting need an unlocked Vault (403 vault_locked).

POST /backup ​

Store app data in the Vault's encrypted local storage. Works while the Vault is locked (after first unlock in session).

Each call without a label creates a new timestamped snapshot (backup-<unix time>, up to 10 per app, oldest auto-rotated). Pass an explicit label to overwrite a named backup - named backups are app-managed state and are never auto-rotated, so an app can keep as many named objects as its data needs (for example, one per conversation or document).

Payload size is capped per object, not per app - read the cap from GET /backup/limits and split larger payloads into parts (e.g. mydata.p0, mydata.p1). For compressed or binary payloads, send data_base64 instead of data with a matching content_type (e.g. application/json+gzip) - the bytes are stored and returned verbatim, and gzipped JSON stays human-readable in the user's Your Data exports. Exactly one of data / data_base64 must be present (400 invalid_data). This route accepts request bodies up to 55 MB (413 body_too_large above that); an object over the 50 MB cap is 413 backup_too_large.

Restore hold: right after the Vault was rebuilt from a restored identity, and until its owner has imported their export or chosen to start fresh, every write and delete is refused with 403 restore_choice_pending (reads stay open). Treat it as "retry later" - an app that wrote first would claim an empty slot the user's import was about to fill.

Request:

json
{
  "client_id": "flowsta_app_abc123",
  "app_name": "ChessChain",
  "content_type": "application/json",
  "data": {
    "polls_created": { "count": 2, "polls": [...] },
    "votes_cast": { "count": 5, "votes": [...] },
    "private_data": {
      "_readme": "Decrypted from encrypted DHT entries.",
      "vote_rationales": { "count": 1, "rationales": [...] },
      "drafts": { "count": 0, "drafts": [] }
    }
  }
}

Human-readable backups

Include _readme fields, use human-readable names (poll_title not just hashes), and decrypt any encrypted entries before including them. The Vault encrypts the backup at rest - no need to double-encrypt.

Use the canonical payload shape

When data follows the canonical v1 backup shape (top-level version: 1, a cells array, and a _summary), the Vault lights up extra UI: per-entry-type counts on the Your Data page ("12 polls, 38 votes"), and the user's CAL §4.2.1 data export inlines the readable view of each record instead of raw bytes. Any other JSON shape still works, but you don't get those features.

Response:

json
{
  "success": true,
  "label": "backup-1774321234",
  "data_size": 1024,
  "created_at": 1774321234
}

GET /backup/limits ​

The Vault's backup contract, so your app sizes its objects against the Vault it's actually talking to instead of hardcoding limits. No link required.

Response:

json
{
  "max_object_bytes": 52428800,
  "auto_snapshot_keep": 10,
  "named_labels_rotate": false,
  "gzip_supported": true
}
FieldMeaning
max_object_bytesMaximum size of ONE backup object. Split larger payloads into named parts
auto_snapshot_keepHow many timestamped (label-less) snapshots are kept before the oldest rotates
named_labels_rotatefalse = named backups are never auto-deleted; store as many objects as your data needs
gzip_supportedThe Vault decompresses content_type values containing gzip for readable exports; raw bytes always round-trip verbatim

Vaults released before this endpoint existed respond 404 - treat that as a single-snapshot world: one backup up to 50 MB, and named labels may rotate at the 10-backup cap.

GET /backup/list ​

List your app's backups. The Vault filters the answer to the caller's own client_id - it never lists other apps' backups to you, because that would reveal which apps the user has linked. Needs a linked origin and an unlocked Vault (403 vault_locked), unlike /backup and /backup/retrieve.

Response:

json
{
  "app_count": 1,
  "total_backups": 3,
  "total_size": 4096,
  "apps": [
    {
      "client_id": "flowsta_app_abc123",
      "app_name": "ChessChain",
      "backup_count": 3,
      "total_size": 4096,
      "last_backup_at": 1709251200,
      "latest_summary": {
        "counts_by_entry_type": { "Game": 12, "Move": 84 },
        "total_records": 96
      },
      "has_manifest": false,
      "conversation_count": 0,
      "labels": ["manifest", "game-42", "game-43"]
    }
  ]
}

apps has one entry (yours) or none. The latest_summary field is present when the most recent backup follows the canonical v1 shape; it is omitted for older / non-canonical backups. has_manifest and conversation_count are hints for the Vault's own Your Data page (apps that store per-object backups under a manifest label plus conv-* labels); most apps can ignore them.

labels (Vault 1.3.0+) lists every label the app has stored, so an app can reconcile its own index against what the Vault holds in one call instead of retrieving per label. Unlabeled snapshots are not listed, and the field is omitted when there are no labels (and by earlier Vaults).

other_devices (Vault 1.6.0+) is added when the person has several devices and any of them holds backups for your app: one entry per other device, with an opaque device id and the backups it holds for you (label, created_at, data_size). From Vault 1.6.1 each entry also carries the device's name, platform (linux, macos, windows) and network generation as the person's device registry has them, so your app can say which device it synced with and tell when a device runs another Holochain generation (devices on different generations do not sync with each other until one updates). Those three are absent when the registry does not know the device yet. Your apps entry is unchanged and lists only what was made on this device. Omitted when there is nothing to list.

json
{
  "other_devices": [
    { "device": "7f3a…", "backups": [{ "label": "recovery", "created_at": 1774321000, "data_size": 512 }] }
  ]
}

GET /devices ​

(Vault 1.6.1+) The person's devices on this identity, for a linked app: each one's name and platform, whether it is the device answering, and when this Vault last heard from it directly. Needs a linked app (not_linked otherwise) and an unlocked Vault (vault_locked).

Response:

json
{
  "devices": [
    { "device": "3c9e…", "name": "Office PC", "platform": "windows", "generation": "holochain-0.6", "this_device": true, "heard_at": null },
    { "device": "7f3a…", "name": "Laptop", "platform": "macos", "generation": "holochain-0.6", "this_device": false, "heard_at": 1774321060 }
  ],
  "announce_every_secs": 120,
  "catching_up": false,
  "known_count": 2
}

device is the same opaque id as in /backup/list other_devices. Every unlocked Vault tells the person's other devices it is there at least every announce_every_secs; heard_at (seconds) is when this Vault last heard such an announcement, null if it has not this session. A device heard within about two announcement periods has its Vault unlocked and reachable: a backup posted now reaches it in a minute or so. One not heard for longer is locked, asleep or off, and catches up when it is back - say so in your app, so a change that waits for it is not a mystery. Removed devices are left out.

Just after the Vault starts or is unlocked it is still reading the identity's devices: the list can be short. catching_up is true then, and known_count is how many devices (this one included) the Vault already knows of - show "catching up", not "this is the only device".

POST /backup/retrieve ​

Retrieve a stored backup. Omit label to get the backup labeled latest if one exists, otherwise the newest backup of any label. Works while locked after the first unlock of the session.

Request:

json
{
  "client_id": "flowsta_app_abc123",
  "label": "backup-1774321234"
}

Across the person's devices (Vault 1.6.0+): add across: "devices" with a label to get the newest backup under that label on any of the person's devices, this one included. The answer then carries from_device: null when the backup was made on this device, otherwise the same opaque id /backup/list uses. Without across, this device's own slot is read as before; older Vaults ignore the field.

One named device's copy (Vault 1.6.1+): with across: "devices", add device (an id from /backup/list other_devices) to read the copy held from that one device instead of the newest - so a per-device label such as a manifest can be read from every sibling. An unknown device or label answers 404 backup_not_found.

json
{
  "client_id": "flowsta_app_abc123",
  "label": "manifest",
  "across": "devices",
  "device": "0f3a9c…"
}
json
{
  "client_id": "flowsta_app_abc123",
  "label": "recovery",
  "across": "devices"
}

Response:

json
{
  "success": true,
  "client_id": "flowsta_app_abc123",
  "app_name": "ChessChain",
  "label": "backup-1774321234",
  "created_at": 1774321234,
  "data_size": 1024,
  "content_type": "application/json",
  "data": { "...": "the stored JSON" }
}

A content_type containing gzip comes back as data_base64 (the stored bytes, verbatim) instead of data; a JSON content type comes back parsed in data; any other content type comes back as a hex string in data.

Errors are part of the contract - a write guard must be able to tell an empty slot from one it must not touch:

StatuserrorMeaning
400label_requiredacross: "devices" was sent without a label
404backup_not_foundThe slot is genuinely empty - safe to write into (with across, no device holds that label)
409identity_mismatchA backup exists under this label but does not decrypt under the active identity's key: it was written by a different identity (or is corrupted). Never overwrite it
500backup_unreadablePresent but damaged before decryption (unparseable file, I/O error)

POST /app-secret ​

(Vault 1.6.1+) A 32-byte secret for the calling app and a label, the same on every one of the person's devices: the Vault derives it from the identity seed (HMAC-SHA256 over flowsta-app-secret-v1, the app's client_id and the label, each length-prefixed), so an app installed on several devices with the same identity gets one private network seed - or one data key - with nothing to copy and nothing to wait for. Scoped to the calling app: one app cannot ask for another's. Needs a linked origin and an unlocked Vault; nothing is written or sent anywhere.

Request:

json
{ "label": "network" }

A label is 1-64 of letters, digits, ., _ or -.

Response:

json
{
  "success": true,
  "label": "network",
  "secret_hex": "9f2c…(64 hex)",
  "derivation": "flowsta-app-secret-v1"
}
StatuserrorMeaning
400label_invalidThe label is empty, too long or has other characters
403not_linkedThe origin is not a linked app
403vault_lockedUnlock the Vault first
409not_availableThe identity has no device-hosted seed (a legacy custodial Vault)

The SDK's getAppNetworkSecret (SDK) wraps this with the fallbacks an app needs on older Vaults.

POST /backup/delete ​

Delete one stored backup, or all of your app's backups. Needs an unlocked Vault (403 vault_locked) and is held during the restore hold (403 restore_choice_pending).

Request:

json
{
  "client_id": "flowsta_app_abc123",
  "label": "latest"
}

An omitted label means latest - it does not delete everything. To delete every backup for your app, send "delete_all": true instead of a label.

Response: { "success": true } for one label (404 backup_not_found when that label does not exist), or { "success": true, "deleted_count": 3 } with delete_all. An I/O failure is 500 delete_failed, never reported as absence.

Canonical backup payload (v1) ​

When you post a backup whose data follows this shape, the Vault recognizes it and unlocks per-entry-type summaries on the Your Data page plus human-readable inlining in the user's CAL §4.2.1 data export.

json
{
  "version": 1,
  "_readme": "Your YourApp data, backed up automatically by Flowsta Vault…",
  "license": "Cryptographic Autonomy License v1.0 (CAL-1.0)",
  "app": { "name": "YourApp", "client_id": "flowsta_app_…" },
  "agent_pub_key": "uhCAk…",
  "exported_at_iso": "2026-05-29T03:42:11Z",
  "_summary": {
    "countsByEntryType": { "Game": 12, "Move": 84 },
    "totalRecords": 96
  },
  "cells": [
    {
      "role_name": "games",
      "_readme": "Each record below is one thing you did…",
      "records": [
        {
          "entryType": "Game",
          "actionHash": "uhCkk…",
          "createdAtMs": 1716969780000,
          "human_readable": {
            "name": "Friday Night Chess",
            "opponent": "uhCAk…",
            "started_at": 1716969780000
          },
          "raw_record": {
            "entry_b64": "<base64-encoded MessagePack bytes>",
            "action_address": "uhCkk…",
            "action_type": "Create"
          }
        }
      ]
    }
  ]
}

What the Vault checks, and what the SDK needs. Recognition needs version: 1 and a cells array; the Your Data counts need _summary.totalRecords and _summary.countsByEntryType. The Vault does not inspect individual records. The two views per record are what the export and the restore consume:

  • human_readable - the decoded entry as plain JSON. This is what the user sees in their downloadable CAL data export.
  • raw_record - at minimum, entry_b64 (the entry's MessagePack bytes, base64-encoded). This is what restoreFromVault hands to your restore_record dispatcher when the user reinstalls.

Extra top-level fields are preserved verbatim through the export pipeline. Apps that generate an independent Holochain agent key not derived from the Flowsta seed carry it in an app_keys block at the top level - it is what makes their data export self-sufficient (records and the means of authorship), and what a fresh machine adopts to continue as the same agent:

json
{
  "version": 1,
  …
  "app_keys": {
    "_readme": "The agent seed YourApp signs with. Import it on a new machine to keep authoring as the same agent.",
    "device_seed_hex": "abc123…",
    "version": 1
  }
}

An app whose install predates its escrowable seed should write an explicit "device_seed_hex": null with a _readme saying how to upgrade, rather than omitting the block - an honest "no key here" beats a silent one. Writers that carry app_keys must also guard the slot: retrieve before writing, and refuse to overwrite a backup escrowing a different key than the local one (see the write guards).

The SDK's dumpCellStateForBackup() builds this shape from an AdminWebsocket instance. For apps whose frontend only has an AppWebsocket, generate it from a Tauri command using zome queries - see ProofPoll's build_canonical_backup for a worked example.

Raw Signing ​

POST /sign ​

Sign raw data with the Vault's agent key. Linked apps only (403 not_linked) - otherwise any local process could forge Holochain signatures as the user - and the Vault must be unlocked (403 vault_locked). For document/file signing, use /sign-document instead.

Approval: every request shows an approval dialog naming the app, with one exception: within 120 seconds of the user approving a /link-identity from the same origin, the request proceeds without a dialog - that is the linking ceremony's own agent-pair signature, covered by the approval just given. The dialog waits 60 seconds; a denial or no answer in time is 403 user_denied (this route does not use 408 timeout).

Request (type: "bytes"):

json
{
  "type": "bytes",
  "bytes": "base64-encoded-data",
  "reason": "Sign this document"
}

type is required and must be "bytes" or "action" (400 unknown_type). bytes is base64 with the standard alphabet (400 missing_bytes, 400 invalid_base64). Decoded bytes starting with flowsta- are refused with 403 reserved_prefix - Flowsta's own sign-in protocols sign strings with that prefix, and a linked app must not be able to mint one. The reason field is accepted for audit; the dialog shows the app name, the type, and the payload size.

Response (bytes):

json
{
  "success": true,
  "signature": "base64-encoded-ed25519-signature",
  "agent_pub_key": "uhCAk..."
}

Request (type: "action"): { "type": "action", "action": { ...a JSON object... } } (400 missing_action, 400 invalid_action). The Vault serializes the object to JSON bytes and signs those.

Response (action):

json
{
  "success": true,
  "signed_action": { "...": "the action you sent" },
  "signature": "base64-encoded-ed25519-signature",
  "agent_pub_key": "uhCAk...",
  "did": "did:flowsta:uhCAk...",
  "signed_at": "2026-07-07T18:30:00Z"
}

flowsta:// URLs ​

The Vault registers the flowsta:// URL scheme when it is installed. A web page opens one of these links from a click (the browser's protocol hand-off needs a user gesture), the operating system delivers it to the current user's Vault, and the Vault comes to the front. Success is not detectable from the page - if no Vault is installed most browsers show nothing - so always keep your fallback visible (a typed code, a download link).

URLWhat the Vault does
flowsta://open/v1Brings the Vault window to the front. Any flowsta:// URL the Vault does not otherwise recognize does the same
flowsta://relay/v1?code=XXXX-XXXXHands a relay login code to the Vault (normalized to its 8 letters), which shows the approval screen - locked or unlocked. The SDK's openVaultDeepLink() opens this URL
flowsta://claim/v1?nonce=<hex>Records the nonce (8-64 hex characters, lower-cased) so it appears in /status claims for two minutes (at most the 8 most recent are kept). Vault 1.5.0+; older Vaults only come to the front

Why claim exists. Loopback ports are shared by every user account on a computer, so a page probing 27777-27779 cannot tell whose Vault answered. The operating system can: it routes flowsta:// to the handler of the person at the keyboard. A page that makes a fresh nonce, opens flowsta://claim/v1?nonce=..., and then looks for that nonce in each port's /status has found this user's Vault.

Error Responses ​

All endpoints return errors as a non-2xx HTTP status with this envelope - there is no success or message field:

json
{
  "error": "error_code",
  "description": "Human-readable description"
}

description may be null for some errors. The SDK maps these codes to typed error classes (VaultLockedError, UserDeniedError, …).

Malformed requests are rejected by the HTTP layer before the envelope applies, as plain text: invalid JSON is 400, a missing required field is 422, and a POST without Content-Type: application/json is 415.

Error CodeHTTP StatusDescription
vault_locked403Vault is locked - ask the user to unlock it
vault_never_unlocked403Vault hasn't been unlocked yet this session (/backup and /backup/retrieve need one unlock first)
user_denied403User rejected the approval dialog (on /sign, also no answer within 60 s)
not_linked403Your origin isn't a linked app - call /link-identity first (/sign and every backup route except /backup/limits)
client_id_mismatch403The client_id in the request doesn't match the one linked to your origin
tier_forbidden403Flowsta pages and linked apps only (commit: true on /sign-document); Flowsta pages only (/profile-update, /revoke-signature, /set-thumbnail, /signatures, /connections)
forbidden403/revoke-identity from an origin that is neither Flowsta nor the linked app revoking its own link
reserved_prefix403 / 400The bytes to sign start with flowsta- - reserved for Flowsta's own protocols (403 on /sign and /sign-document; 400 on /authenticate)
quota_exceeded403The period's signing quota is used up (sponsor pool and personal quota)
restore_choice_pending403The Vault was just restored and waits for its owner to import or start fresh - backup writes and deletes resume after that; retry later
app_not_found / app_disabled / invalid_client_id403Flowsta did not recognize, or has disabled, this client_id (passed through from the registration check on /link-identity)
other_user403This Vault belongs to another user account on this computer - look for the one that answers you (1.5.0+)
identity_mismatch409The Vault holds a different identity than expected_identity (1.5.0+); or, on /backup/retrieve, the stored backup was written by a different identity - never overwrite it
identity_unconfirmed409expected_identity was sent but the Vault has no identity set up (1.5.0+)
agent_linked_elsewhere409This app agent is already linked to another identity in this Vault (1.5.0+)
missing_client_id400No client_id provided on /link-identity
invalid_agent_pub_key400app_agent_pub_key is not a uhCAk... key
invalid_challenge400/authenticate challenge is not valid base64
missing_bytes / invalid_base64 / missing_action / invalid_action / unknown_type400/sign request shape errors
invalid_file_hash / invalid_comment / invalid_supersedes / invalid_thumbnail400/sign-document and /set-thumbnail field limits (64 hex chars; 280 chars; 39-byte hex hash; data:image/... under 300 KB)
invalid_action_hash / invalid_reason400/revoke-signature and /set-thumbnail: 39-byte hex action hash; reason 280 chars max
invalid_display_name / invalid_profile_picture / nothing_to_update400/profile-update field limits (80 chars; data:image/... or https:// under 800 KB; at least one field)
invalid_data400/backup: provide exactly one of data (JSON) or data_base64 (valid base64)
backup_failed400/backup: the Vault could not store the object (the description says why)
backup_not_found404No backup under that label (/backup/retrieve, /backup/delete) - the slot is empty
unknown_job404/op-status: no such job, or it expired (10 minutes after its last update)
timeout408User didn't respond to the approval dialog within 60 seconds (/link-identity, /authenticate, /sign-document, and the management routes)
backup_too_large413One backup object exceeds the Vault's 50 MB limit (max_object_bytes)
body_too_large413Request body over the route's cap (8 MB; 55 MB on /backup)
api_unreachable503Cannot reach Flowsta to verify a client_id on its first link (502 if Flowsta answered with an unusable body)
conductor_not_ready503The Vault's conductor is still starting up - retry in a moment
backup_unreadable500/backup/retrieve: the file is present but damaged before decryption
commit_failed500/sign-document with commit: true: the signature was not published; nothing changed
write_failed / revoke_failed / delete_failed500A profile, thumbnail, revocation, or backup-delete write failed on the device
no_device_seed500The Vault was created before signing support and has no device seed - the user re-creates it
internal_error500The approval channel closed unexpectedly, or the response could not be encoded

Next Steps ​

Documentation licensed under CC BY-SA 4.0.