Developer Guide
Add document signing to your app. All signing goes through the user's Flowsta Vault: the keys never leave their device, and the user approves every signature in a Vault dialog - no one in between. Your app's job is to hash the file, request the signature, and handle the user's answer.
Two SDKs, depending on your platform - and they differ in what comes back:
| Path | For | SDK | What You Get |
|---|---|---|---|
| Web Apps (OAuth) | Websites using "Sign in with Flowsta" | @flowsta/auth | The Vault signs for your app after the user approves. The signature is returned to your app and is not published to the network - nobody else can verify the file by it |
| Desktop Apps (Vault IPC) | Holochain or Tauri apps running alongside Vault | @flowsta/holochain 3.6.0+ | After the user approves, the Vault signs and - when your app is linked to the person's identity - publishes the signature to the Sign It network from their device, so anyone can verify the file |
Only Flowsta's own pages and linked apps may publish through the Vault. Everyone else gets a signature the Vault made for them; the person publishes from their own Vault's Sign It page when they want the world to see it.
Prerequisites
- Register your app at dev.flowsta.com to get a
clientId - Choose your SDK:
- Web apps:
npm install @flowsta/auth - Desktop Holochain apps:
npm install @flowsta/holochain
- Web apps:
Web Apps (OAuth)
Setup
import { FlowstaAuth, hashFile } from '@flowsta/auth';
const flowsta = new FlowstaAuth({
clientId: 'your_client_id',
redirectUri: 'https://your-app.com/callback',
scopes: ['openid', 'display_name', 'sign'], // Request signing permission
});The sign scope appears on the consent screen as Sign files on your behalf - "Create and manage signatures as you". Even with the scope granted, every individual signature still requires the user's explicit approval in their Vault.
Sign a File
// 1. Hash the file client-side (never uploaded)
const hash = await hashFile(file);
// 2. Request the signature - the user approves in their Vault
const result = await flowsta.signFile({
fileHash: hash,
intent: 'Authorship',
contentRights: {
license: 'cc-by',
aiTraining: 'not_allowed',
contactPreference: 'allow_contact_requests',
},
});
// 3. Use the result
console.log(result.agent_pub_key); // Who approved (their agent key)
console.log(result.signed_at); // ISO 8601 string from the Vault
console.log(result.action_hash); // null - the signature was not publishedsignFile() probes for a running Vault on the user's machine. When Vault is running, it signs there: the user sees an approval dialog naming your page (by its origin) and showing the first characters of the hash, and the key never leaves their device. The dialog does not show the license, AI disclosure or intent you supplied.
What comes back is not on the network. A web app cannot ask the Vault to publish, so action_hash is always null, the file will not show as signed on the verify page, and no one else can verify it by this signature. The result tells your app that this person, holding this key, approved signing this hash at this time. If the person wants a public, verifiable signature, they drop the file on their Vault's Sign It page and sign it there.
Content rights: @flowsta/auth 2.6.0+ translates the camelCase keys above (aiTraining, contactPreference, commercialLicensing) to what the Vault reads; snake_case keys (ai_training, ...) are accepted too. Before 2.6.0 the camelCase keys were dropped silently - upgrade.
Handle the User's Answer
import {
FlowstaAuthError,
VaultRequiredError,
VaultBlockedError,
VaultIdentityMismatchError,
UserDeniedError,
} from '@flowsta/auth';
try {
const result = await flowsta.signFile({ fileHash: hash, intent: 'Authorship' });
} catch (e) {
if (e instanceof UserDeniedError) {
// The user declined in the Vault dialog - respect that, don't retry automatically
} else if (e instanceof VaultIdentityMismatchError) {
// The local Vault holds a DIFFERENT identity than this session
// (2.4.0) - tell the user to switch the Vault to the identity they are
// signed in with here; a signature must never come from someone else's Vault
} else if (e instanceof VaultBlockedError) {
// The BROWSER blocked the page from reaching the Vault (Chrome 142+
// local network access permission, Brave's localhost block) (2.5.0).
// Point the user at the browser's site settings - not at "install the Vault"
} else if (e instanceof VaultRequiredError) {
// No Vault can sign for this identity - prompt the user to open
// (or install) Flowsta Vault: https://flowsta.com/vault
} else if (e instanceof FlowstaAuthError && e.code === 'timeout') {
// The user did not answer the Vault dialog within ~60 s
}
}Signing needs the Vault
When no Vault is reachable, signFile() throws VaultRequiredError - signing keys live only in the user's Vault, so there's nothing else that can sign. Prompt the user to open (or install) Flowsta Vault and try again. When the browser is what stands in the way, you get VaultBlockedError instead - the Vault may well be running.
Batch Signing
One approval per signature
The Vault approves one document at a time - that per-signature approval is the point. When a Vault is detected, signBatch() throws FlowstaAuthError with code batch_requires_individual_approval; call signFile() per file instead, and each shows its own Vault prompt. Users can batch-sign in the Vault's own Sign It page.
// Instead of signBatch - one approval per file:
for (const file of files) {
const hash = await hashFile(file);
await flowsta.signFile({ fileHash: hash, intent: 'Authorship' });
}Users who want true batch signing can drop the whole folder into the Vault's own Sign It page.
Verify a File
const result = await flowsta.verifyFile(hash);
if (result.count > 0) {
result.signatures.forEach(sig => {
console.log(`Signed by: ${sig.signer_did || sig.signer}`);
console.log(`License: ${sig.content_rights?.license}`);
console.log(`AI Training: ${sig.content_rights?.ai_training}`);
console.log(`Revoked: ${sig.revoked}`);
});
}Verification is a public endpoint - no authentication required, free on every plan, rate-limited to 30 requests per minute per IP.
Note: signatures made in a Vault reach the server nodes by gossip, typically within minutes of signing. A just-signed file may briefly show no results.
Check Content Rights
const rights = await flowsta.getContentRights(hash);
if (rights.signed) {
console.log(`${rights.signerCount} signature(s)`);
rights.rights.forEach(r => {
if (r.aiTraining === 'NotAllowed') {
console.log('Do not use for AI training');
}
});
}rights holds only non-revoked signatures that declared rights; signerCount is the total signature count, revoked ones included.
OAuth Scopes
| Scope | Purpose | Required For |
|---|---|---|
sign | Request signatures on behalf of the user | signFile() (consent), POST /revoke, POST /set-thumbnail |
verify | Authenticated verification and quota lookups | GET /quota, GET /my-signatures (verification endpoints are public and work without auth) |
Desktop Apps (Vault IPC)
For Holochain apps or Tauri desktop apps running alongside Flowsta Vault. Signing happens locally via IPC - the user approves in Vault. A linked app (one the person approved through linkFlowstaIdentity, see the Holochain SDK) can publish the signature to the network from the person's device.
Setup
npm install @flowsta/holochainCheck if Vault is Available
import { getSigningStatus } from '@flowsta/holochain';
const status = await getSigningStatus();
if (status.available) {
// Vault is running, unlocked, and ready to sign
showSignButton();
} else if (status.vaultRunning && !status.vaultUnlocked) {
showMessage('Please unlock Flowsta Vault to sign files');
} else {
showMessage('Install Flowsta Vault to sign files');
}Sign a File
import { signDocument } from '@flowsta/holochain';
const result = await signDocument({
clientId: 'your_client_id', // From dev.flowsta.com
appName: 'ArtStudio', // Shown in Vault approval dialog
fileHash: 'a7f3b9c1e2d4...', // SHA-256 hex (64 chars)
label: 'Illustration.png', // Optional: shown in approval dialog
intent: 'authorship',
contentRights: {
license: 'cc-by',
aiTraining: 'not_allowed',
contactPreference: 'allow_contact_requests',
},
// publish: true, // Default: true when this app is linked
});
console.log(result.signature); // Base64 Ed25519 signature
console.log(result.agentPubKey); // uhCAk... format
console.log(result.published); // true when the record is on the network
console.log(result.actionHash); // Hex action hash of the record, or nullThe user sees an approval dialog in Vault showing your app name (for a linked app, the name they approved at link time), the file label, and the first characters of the hash. When the signature will be published, the dialog also says whose signing quota it draws from and that it will be published from this device to the Sign It network. The dialog does not show the license, AI disclosure or intent you supplied.
If they approve, Vault signs the hash with their Ed25519 key. With publish on (the default for a linked app), the Vault commits the record to the person's own signing cell; it gossips to Flowsta's network automatically, typically verifiable everywhere within minutes, and result.actionHash is the record's hex action hash. Without publishing - publish: false, or an app that is not linked - the signature is returned to your app only: published: false, actionHash: null, nothing on the network, nobody else can verify it.
The SDK exposes no comment (Public Note), thumbnail or perceptual_hash option; a signature published through signDocument carries none of those.
Handle Errors
import {
signDocument,
FlowstaHolochainError,
VaultNotFoundError,
VaultBlockedError,
VaultLockedError,
UserDeniedError,
PublishForbiddenError,
QuotaExceededError,
IdentityMismatchError,
} from '@flowsta/holochain';
try {
const result = await signDocument({ ... });
} catch (e) {
if (e instanceof VaultNotFoundError) {
// Vault is not running - prompt user to open it
} else if (e instanceof VaultBlockedError) {
// The browser blocked the loopback request (3.1.0) - browser site settings, not "install"
} else if (e instanceof VaultLockedError) {
// Vault is locked - prompt user to unlock
} else if (e instanceof UserDeniedError) {
// User rejected the signing request
} else if (e instanceof PublishForbiddenError) {
// publish was asked for but this app is not linked - link it
// (linkFlowstaIdentity) or sign with publish: false
} else if (e instanceof QuotaExceededError) {
// The signing quota for this period is used up - e.description says whose
} else if (e instanceof IdentityMismatchError) {
// The Vault holds a different identity than this app is linked to - stop
} else if (e instanceof FlowstaHolochainError && e.code === 'timeout') {
// The user did not answer the Vault dialog within ~60 s
}
}Other FlowstaHolochainError codes the Vault can return: invalid_file_hash (not 64 hex characters), reserved_prefix (the hash bytes begin with flowsta-), conductor_not_ready (the Vault is still starting - try again in a moment), commit_failed (the publish could not be committed).
What Vault Signing Gives You
| Vault signing | |
|---|---|
| Where signing happens | The user's device - keys never leave it |
| User approval | Explicit dialog per signature |
| Publishing | From the person's own device, for linked apps and the Vault's own Sign It page; web apps get an unpublished signature |
| Whose quota | Published signatures only: your org's sponsored pool when your app initiates and has a plan, otherwise the person's personal quota - the dialog says which |
| File integrity checks | Vault runs them automatically for its own Sign It page; not for SDK requests |
| Perceptual hashing | Vault generates it automatically for its own Sign It page; not for SDK requests |
| Network visibility | Minutes after publishing (gossip from local to network) |
| Offline support | The Vault's own Sign It page signs offline and publishes on reconnect; an SDK publish needs the Vault's conductor up and its quota state known |
Signing Quotas & Sponsored Signing
Quotas apply to published signatures - the ones that go on the network from the Vault's Sign It page or from a linked app. A signature the Vault returns to a web app unpublished draws no quota.
Sponsored signing: your org's pool pays
When your app initiates a published signature (your client_id is on the request and your organization has a signing plan), the signature draws from your organization's monthly signing pool - the user's personal quota is untouched. The Vault approval dialog tells the user whose quota pays ("Sponsored by ArtStudio - this signature doesn't use your personal quota."), so there's never a hidden cost.
| Developer tier | API tier string | Included signs / month | Overage |
|---|---|---|---|
| Free | free | 250 | None - hard cap |
| Spark | starter | 2,500 | $1.00 per additional 100 signs |
| Pro | pro | 25,000 | $0.50 per additional 100 signs |
| Enterprise | enterprise | Custom | Custom |
If your org's pool is exhausted (Free tier at its cap), the signature falls back to the user's personal quota - and the Vault dialog says so ("ArtStudio's signing pool is used up for this period - this signature will use your personal quota."). Paid tiers never block: overages are metered and billed automatically.
Personal quotas (user plans)
Signatures a user initiates themselves (Vault UI, no sponsoring app), and app-initiated ones once the sponsor pool is dry, count against their personal plan:
| User plan | API tier string | Signs / month |
|---|---|---|
| Free | free | 2 |
| Premium | premium | 100 |
| Premium Plus | premium_plus | 1,000 |
Personal quotas are a hard block. Through the Vault, an over-quota publish is refused before the approval dialog with HTTP 403, error quota_exceeded and a description that says which quota ran out (e.g. "You've used all 2 signatures for this period (free plan). Upgrade at flowsta.com/dashboard/premium to keep signing."); @flowsta/holochain throws QuotaExceededError with that description. The deprecated REST POST /sign answers HTTP 402 with quota_exceeded plus scope, tier, used, limit, resets_at and upgrade_tier - see the Verification API.
Verification is free on every plan - quotas apply to signing only.
Quota endpoints
Check quota state before attempting a sign (e.g. to render a quota meter):
# Authenticated (JWT session or OAuth token with `verify` scope)
curl "https://auth-api.flowsta.com/api/v1/sign-it/quota" \
-H "Authorization: Bearer <token>"{
"allowed": true,
"scope": "community",
"tier": "premium",
"used": 12,
"limit": 100,
"resets_at": "2026-08-01T00:00:00.000Z"
}scope is "community" (personal quota) or "org" (your app's pool, when authenticated with an OAuth token). Org responses on paid tiers also include overage_signs and overage_cents_per_100; a blocked response includes upgrade_tier. An OAuth app that belongs to no organization gets tier: "unknown", limit: null and resets_at: null.
# Public lookup by agent key (used by Vault). Add client_id to see the
# sponsor pool your app would draw from:
curl "https://auth-api.flowsta.com/api/v1/sign-it/quota/by-agent?agent_pub_key=uhCAk...&client_id=your_client_id"With client_id, the response is the sponsoring org's pool while it has room, plus a sponsor object:
{
"allowed": true,
"scope": "org",
"tier": "starter",
"used": 480,
"limit": 2500,
"resets_at": "2026-08-01T00:00:00.000Z",
"overage_signs": 0,
"overage_cents_per_100": 100,
"sponsor": { "app_name": "ArtStudio", "exhausted": false }
}When the org pool is dry, the same call returns the user's personal quota with sponsor.exhausted: true. Unknown agent keys return 404 agent_not_found; the endpoint is rate-limited to 60 requests per minute per IP.
Shared Features
Public Note
Signatures can carry a signer comment - a Public Note of up to 280 characters shown to verifiers alongside the signature. Users add it in the Vault signing flow; it also exists as a comment field on the legacy /sign API. Neither SDK exposes it as an option.
Deep Linking to Verification
Link directly to the verification page for a specific file:
https://flowsta.com/sign-it/verify/a7f3b9c1e2d4.../The page verifies the hash server-side and shows results. The older form https://flowsta.com/sign-it/?hash=... (the verify_url the API returns) redirects there with a 301.
Webhooks
sign.created and sign.revoked webhooks fire only for signatures made through the deprecated REST signing endpoints, and only to the app that made them; signatures made in the Vault - including ones a linked app publishes through signDocument - raise no webhook today.
Rate Limits
| Endpoint | Limit |
|---|---|
POST /sign, POST /sign-batch (deprecated) | Quota-gated, not rate-limited - over-quota returns 402 quota_exceeded |
GET /verify | 30 requests/minute per IP |
POST /verify-file | 30 requests/minute per IP |
POST /verify-fuzzy | 30 requests/minute per IP |
GET /creator/:id | 30 requests/minute per IP |
GET /quota/by-agent, POST /quota/sync-by-agent | 60 requests/minute per IP |
GET /quota | Authenticated; no per-IP limit |
GET /badge | 60 requests/minute per IP |
GET /content-rights | 60 requests/minute per IP |
POST /contact | 3 requests/hour per IP |
Next Steps
- Verification API - Raw API endpoints for custom integrations
- SDK Reference - Complete method reference
- Content Rights - All content rights fields and values