Start here

What are you anchoring?

Pick one path. Every path produces the same portable .mbnt bundle that any reviewer can verify against the BSV chain — no Satsignal account needed.

Anchoring documents or evidence rather than agent runs? Files or reports (first card below) is the start-to-finish recipe; the litigation-evidence playbook covers preservation and the qualified-witness declaration template; and the no-code path below never touches a terminal.

No-code path. Everything above also works without writing a line of code:
  1. Verify anything at proof.satsignal.cloud/verify — no account, ever.
  2. Create proofs in the browser: sign in by email magic link at app.satsignal.cloud → create a folder → anchor a file from the folder page (hashing runs in your browser; the file itself never uploads) → for selective disclosure, open the folder’s Disclosure Redaction Tool — also fully in-browser, nothing uploaded.
  3. API keys are for programs. A Bearer key drives the read/operational dashboard surfaces programmatically, but interactive browser use rides the magic-link session; keys are minted at /w/<workspace>/keys after signing in.
·Docs & API

Build with Satsignal.

Every integration produces the same canonical evidence object — satsignal.provenance.v1 — and anchors its sha256 to the public Bitcoin SV chain. Verification runs offline in any browser or CI runner against any public block explorer — no Satsignal account or API key required at verify time.

Fast path: pip install satsignal-clisatsignal anchor <file> --broadcast. Anchoring needs SATSIGNAL_API_KEY; verification needs no account — drop the .mbnt at proof.satsignal.cloud/verify or check it by hand. Verify paths →

·Core concepts

The core words this whole API turns on.

Plain-English glosses for the canonical terms. Each term keeps its exact name everywhere on this site — this is what each one means.

  • anchor — a timestamped commitment recorded on-chain
  • proof — the verifiable claim you keep and share — the fingerprint, the chain anchor, and the metadata used later to check timing and tamper-evidence
  • bundle — the portable .mbnt file a verifier uses to re-check the proof
  • folder — the workspace namespace each proof files under (the folder_slug on every request)
  • sealed — a private proof outsiders can’t test against the original unless you share the bundle
  • verifier — the browser tool that checks the proof, the matching item, and the on-chain anchor
  • manifest — a list of items proven together as one set

Standard vs Sealed is your one real choice — the privacy level. A Standard anchor is publicly checkable against the original; a Sealed anchor keeps the original private until you share the bundle (stored, or blind if you withhold the salt) — same chain, same proof. A manifest isn’t a third privacy mode: it’s the evidence shape — many items under one Merkle root — and works with either. All are documented under the endpoint.

Curious exactly which bytes land on chain (and which never do)? The on-chain payload walkthrough decodes a real anchor byte by byte.

When you're authoring an integration, the first non-obvious decision is which bytes to hash. See: what bytes do I hash?

Wire-level responses emit the canonical field names (proof_id, folder_slug); the legacy spellings remain accepted on requests forever. See the full compatibility map →

·What to look for

How this differs from logs and timestamping services.

If you already keep audit logs, or you’ve used a timestamping service, the useful question isn’t “which product” — it’s what property each one actually gives you. Two distinctions matter operationally.

An operator-controlled log vs. an independently verifiable proof.

An append-only log — an application log, an audit trail, a cloud activity feed — is written and held by the same party whose actions it records. Checking it means trusting that party not to have rewritten or pruned it; the log is its own only witness. A proof’s evidence is a Bitcoin SV transaction the operator does not control and cannot alter or backdate once it is broadcast. Anyone can re-hash the bundle and check it against the chain — no account, no call to us. What to look for in any proof system: can a third party verify it without trusting whoever produced it?

A shared-root timestamping service vs. a per-customer bundle.

Many timestamping services batch many customers’ hashes into one tree and anchor a single root. That shows your hash was in a batch at a point in time, but verifying usually means asking the service for the inclusion path, and the service decides what is revealed and how long it is kept. A per-customer bundle ships the full proof material to you — the canonical bytes, the Merkle path for any selective-disclosure rows, and the transaction reference — so the check runs entirely on artifacts you already hold, and you can reveal one row of a batch without exposing the rest. What to look for: do you receive enough to verify offline, or only a pointer back to the service?


Core

·Ingestion routes

Routes into satsignal.provenance.v1.

Same canonical object, many ways in. Each route below normalizes its input into the canonical model and anchors the canonical sha256. The product is the canonical model; these are routes into it. Standards bridges (C2PA, Sigstore, SLSA, OpenTelemetry, MCP) project in the same way — by digest, not by replacing the standard’s own semantics.

CLI

Fastest path

satsignal anchor <file> --broadcast returns a chain-confirmed proof. Group anchors with --folder <slug> (the legacy --matter flag is still accepted inbound). A satsignal-cli verify verb that adds bundle-v1 verification with chain-confirm by default shipped in satsignal-cli 0.5.0. Right for file-anchor workflows, CI, and one-off shells.

Verification needs no CLI today — three paths work now: (1) the hosted verifier at proof.satsignal.cloud/verify — drag-drop the .mbnt, account-free; (2) by hand — unzip the bundle and run sha256sum bundle/canonical.json | cut -c1-40, compare to doc_hash_expected, then resolve the txid in any block explorer; (3) python agent_anchor.py verify-handoff (the stdlib helper at scripts/agent_anchor.py). See programmatic verification. LangChain agent? Use langchain-satsignal — same primitives, dropped in as agent components.

SPV verification — planned, not yet shipped. A standalone satsignal verify CLI (with --spv / --min-confirmations) is planned but not yet shipped: instead of trusting a public BSV node’s RPC for chain-confirmation, it would maintain a local headers store (~72 MB) and prove inclusion via SPV — Merkle path against the bundle plus the headers chain, no third-party trust beyond Bitcoin’s PoW. Until it ships, the trust-minimizing path today is the by-hand hash check followed by resolving the txid against your own node. That serves CI runners that don’t want to trust a single public node, air-gapped or low-trust verifiers, and auditors who want the strongest standard for inclusion.

PY

Python session helper

agent_anchor.py wraps the four-part agent-session pattern — policy snapshot, a commitment per decision, an evidence-bundle manifest, plus a handoff.json for offline audit (N + 2 chain anchors) — into a six-line Session() context manager. Stdlib only. Right when your runtime is Python and you want the agent-shaped anchoring discipline without writing it from scratch.

Doc page: Agent session proofs · Worked example: example_agent_snapshot.py.

HTTP

Direct API

Bearer-auth JSON POST /api/v1/anchors. Mode (standard / manifest / sealed) inferred from body shape; category is an explicit field. Right when you're in a non-Python runtime, you want explicit control over the wire shape, or you're embedding anchoring into a custom flow.

Step-by-step: Quickstart §01 →
Endpoint reference: §02 →

MCP

Drop into any MCP agent

pip install satsignal-mcp exposes ten tools over the Model Context Protocol — six core (anchor_file / anchor_text / anchor_json / lookup_hash / verify_file_against_bundle / chain_confirm_bundle), three selective-disclosure tools (anchor_disclosable / create_disclosure / verify_disclosure), and a deprecated verify_bundle alias. verify_file_against_bundle is the full verify (re-hashes the original file, detects tampering); chain_confirm_bundle is a fast chain-only check that does NOT detect file tampering. Any MCP-compatible client — Claude Desktop, Claude Code, agent frameworks — can call Satsignal without a custom SDK. Right when your agent runtime already speaks MCP.

github.com/Steleet/satsignal-mcp · PyPI. stdio transport in v0.1; SSE coming later.

MCP-host env-var note. Some hosts (notably Claude Desktop) strip or rebind environment variables at server-launch time, so a SATSIGNAL_API_KEY exported in your shell does not reach the MCP child process. Bind the key explicitly in the host’s server-config block — the env: {...} map in claude_desktop_config.json or equivalent — rather than relying on process-env inheritance. The 401 you’d otherwise see has no breadcrumb.

·The agent-session pattern

A session proof for an agent run.

For agent runs, Satsignal binds the whole run into one proof trail — the policy snapshot anchored before the run starts, a fresh-nonce commitment per decision as it happens, and a final evidence-bundle manifest binding the decision set together. The stdlib-only agent_anchor.py Session() context manager and the ten-tool satsignal-mcp server (drop-in for any MCP-speaking agent runtime) cover the four-part flow without an SDK.

Full guide: A session proof for an agent run →

Worked example: ResearchAgent makes its decisions auditable →

·Or wire it into CI/CD

Anchor build artifacts as a workflow step.

Anchor release artifacts, eval results, security-scan outputs, and provenance manifests directly from a pipeline. The composite GitHub Action (Steleet/satsignal-action@v0) is bash-only on ubuntu-latest — no setup-python, no setup-node — and exposes proof_id, txid, and proof_url (the browser proof page — for programmatic .mbnt download use the response’s bundle_url instead) as step outputs the next step can read. Outside GitHub, the same flow runs with sha256sum + curl + jq in any CI runner, with adapter recipes for GitLab CI, Bitbucket Pipelines, Docker BuildKit, npm, and PyPI PEP 740.

Full guide: Anchor build artifacts as a workflow step →

0110-minute quickstart

Anchor a file or report

The shortest path from zero to a verifiable proof: mint a key, create a folder, hash your artifact locally, and POST /api/v1/anchors — the response carries a chain-confirmed proof_id, txid, and bundle_url for the portable .mbnt. The fields integrators store alongside the original artifact are proof_id, txid, bundle_url, sha256_hex, folder_slug, and a free-text label. Verification then runs in any browser or CI runner with no Satsignal account.

Two timing notes up front: a fresh anchor is fully verifiable immediately, but public explorers take typically 1–5 minutes — occasionally longer (~10) — to index the tx (RECEIVED vs CONFIRMED) — and download your .mbnt at anchor time, because the server-side copy lasts only until you delete the proof — or until an explicit retain_days window lapses (durability across modes).

One privacy note before you anchor: Standard-mode hashes are publicly discoverable — anyone holding the exact file bytes can compute the sha256 and ask the auth-free /lookup_hash endpoint whether this server anchored it. If the existence of an anchor is itself sensitive (a source document, a privileged draft), use sealed mode instead — sealed anchors commit to an HMAC and are excluded from that endpoint by design.

Full guide: Anchor a file or report →

02Reference — The endpoint

One endpoint, three request shapes.

Standard and Sealed are privacy levels; manifest is the request shape for batching many items into one proof. The mode field is a wire discriminator, not a third privacy choice — a sealed batch is still a Sealed proof.

Naming. The canonical vocabulary is proof / folder; responses emit canonical field names only, and requests sent with legacy spellings keep working forever. Full alias map (request fields, routes, scopes, CLI flags, error codes): the Compatibility map.

Every anchor — commitment, policy snapshot, manifest, sealed envelope, or plain file proof — goes through the same call. Mode is inferred from the body shape; category is an explicit field that tags the proof without changing the primitive.

POST https://app.satsignal.cloud/api/v1/anchors
Authorization: Bearer <your-api-key>
Content-Type: application/json
You’ll need.
  • An API key with the anchors:create scope (mint one at /w/<workspace>/keys after signing in).
  • A folder_slug in your workspace — the namespace each proof files under.
  • A SHA-256 fingerprint, or a list of {label, sha256_hex} items, computed locally — the payload stays with you.
  • No human in the loop? Autonomous agents can self-serve the account itself via the agent signup lane (invite code or proof-of-work). Provisioning proof for your own tenants? Mint a keys:admin key once in the dashboard, then POST /api/v1/keys to issue per-tenant scoped sub-keys headlessly — no browser per tenant.
Plans. The free plan ships every new account with 100 anchors per calendar month. Paid production volume is design-partner today: published Starter / Pro / Scale tiers are the planned shape, but self-serve billing is not yet enabled — mail hello@satsignal.cloud to lift your cap or open a design-partner slot. Verification stays free regardless. Full terms →

Create a folder (one-time per project)

A folder is the namespace each proof files under — create one once per integration. The slug becomes the folder_slug on every anchor request below. (Requests to the legacy alias route are still accepted — see the compatibility map.)

POST https://app.satsignal.cloud/api/v1/folders
Authorization: Bearer <your-api-key>
Content-Type: application/json

{
  "name": "AcmeCorp Events Dev",   // human-readable display name
  "slug": "acme-events-dev"        // a-z0-9 with '-' or '_', 2-64 chars, becomes folder_slug
}

Response carries a folder object. Idempotent on (workspace, slug, name) — re-POSTing the identical slug+name returns the existing folder (HTTP 200, duplicate: true); a slug collision with a different name returns 409 duplicate_slug.

Standard mode (single fingerprint)

{
  "folder_slug": "agent-runs-prod",
  "sha256_hex": "d2f658c562d7bccc...d0fecded",
  "file_size": 208,
  "category": "commitment",
  "label": "agent-alpha sealed bid",
  "filename": "alpha-bid.json",
  "session_id": "run-2026-05-09-001"
}

Copy-paste: anchor a file in four lines

Hash the file locally, then POST the fingerprint. The file bytes never leave your machine — only sha256_hex and file_size cross the wire.

export SATSIGNAL_API_KEY=sk_...
SHA=$(sha256sum file.pdf | cut -d' ' -f1)
SIZE=$(stat -c%s file.pdf)
curl -sS https://app.satsignal.cloud/api/v1/anchors \
  -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"folder_slug\":\"agent-runs-prod\",\"sha256_hex\":\"$SHA\",\"file_size\":$SIZE}"

label and filename are transmitted and retained. The file bytes never leave your machine, but these two free-text fields DO cross the wire and are stored with the workspace proof (off-chain, never on chain). Avoid sensitive client names, case references, or document titles in them — use a neutral label. Same guidance as the privacy page, stated here because this is where you type them.

Anchors are chain-tagged with an issuer fingerprint. Every anchor minted through the hosted API carries a 4-byte issuer_id TLV in its on-chain payload. On the hosted tier this is one shared constant (d5b0b0c6 = the first 4 bytes of sha256("did:web:satsignal.cloud")) across all workspaces, so a chain observer can enumerate Satsignal-issued anchors as a class — your workspace is not individually identified, but the fact that an anchor went through Satsignal is public. A deployment that gives each customer their own issuer DID makes that customer’s anchors individually enumerable on-chain. The hosted API has no per-request opt-out today; suppression exists only as an operator-level pipeline setting for self-run deployments. The issuer_id is a discoverability handle, not an authenticity guarantee — because authenticity is “this key signed it,” anyone holding the signing key could mint an anchor carrying the same fingerprint, so verify the bound doc_hash (re-hash your document and match the anchor) rather than relying on the issuer tag. See security §04. Property and detail: /whats-on-chain §4 and /spec-mbnt §11.

All fields except folder_slug and sha256_hex are optional. file_size defaults to 0 when omitted — send the real byte size so the proof and bundle reflect it. session_id is a freeform operator-supplied grouping key (1-128 chars [A-Za-z0-9_.-]) that lets you list every anchor in one agent run via GET /api/v1/anchors?session_id=…. Off-chain by design — for the cryptographic "these anchors belong together" binding use a manifest-backed anchor at end of session. The agent_anchor.py helper bundles both patterns; see Agent session proofs.

Field naming. The request body field is sha256_hex — a 64-character lowercase hex string of the full file sha256. Some bundle-side and on-chain artifacts surface a separate document_hash field (40 characters, a different derivation used for chain-side indexing — see spec-bundle); that field is NOT the request body field, and posting it instead of sha256_hex will return a 400 unknown_field.

Standard mode — multi-proof / selective disclosure (proof_set)

A standard anchor can carry an optional proof_set instead of binding the lone sha256_hex. proof_set.byte_exact is required and must equal the submitted sha256_hex + file_size; alongside it you may add content_canonical (a normalized-content fingerprint) and/or chunk_merkle (a Merkle root over per-chunk leaves — the basis for revealing one page / row / record later without disclosing the rest). The companion proof_leaves carries the leaf hashes.

{
  "folder_slug": "agent-runs-prod",
  "sha256_hex": "d2f658c562d7bccc...d0fecded",
  "file_size": 208,
  "proof_set": {
    "byte_exact":        {"algo": "sha256", "size": 208,
                          "hash": "d2f658c562d7bccc...d0fecded"},
    "content_canonical": {"scheme": "pdf-text-v1", "algo": "sha256",
                          "hash": "9f1c...e3"},
    "chunk_merkle":      {"scheme": "pdf-page-v1", "algo": "sha256",
                          "leaf_count": 12, "root": "a3b9...77"}
  },
  "proof_leaves": {"scheme": "pdf-page-v1",
                   "merkle_leaves": ["...", "..."],
                   "metadata": {"leaf_count": 12}}
}

Only the proof envelopescheme / algo / leaf_count / root — is committed on-chain. The leaves ride off-chain in the bundle’s proofs.json; the verifier recomputes leaf → root from your own file at verify time, so the commitment stays opaque and there is nothing for us to reconstruct. Scheme names and the canonicalization each implies are in the bundle spec. To add a proof_set to a hash already anchored in this folder, pass force_new: true — default dedup keys on sha256_hex only and returns 409 proof_set_requires_force_new rather than silently dropping the richer proofs. Sealed mode accepts the same envelope under HMAC semantics — see Sealed mode — multi-proof / selective disclosure below.

Manifest-backed proofs (Merkle batch, up to 10,000 items)

Merkle-batch up to 10,000 items into one anchor, with selective disclosure per item: reveal one row, file, or decision later and the rest stay opaque. Use cases include bulk evidence bundles, agent decision manifests, and tabular row commitments (the merkle-row-v1 scheme rides on this mode).

The request is the same endpoint with an items[] array instead of a single sha256_hex — its presence is what selects manifest mode (don’t also send sha256_hex / file_size / mode: "sealed"):

{
  "folder_slug": "agent-runs-prod",
  "category": "evidence_bundle",
  "label": "eval run 2026-06-10",
  "items": [
    {"label": "rows/0001.json", "sha256_hex": "63d5c3e67b088a51...d2c33d22dfc724e6"},
    {"label": "rows/0002.json", "sha256_hex": "3cad58f5027bc880...61401d81ca90b766"}
  ]
}

1–10,000 items per anchor. Per item: sha256_hex is required (64 lowercase hex chars); label is an optional string (≤ 256 chars, default "") that is hashed into the leaf, so a swapped label changes the leaf even if the underlying sha is identical. One manifest anchor counts as one anchor against quota regardless of item count. The response adds leaf_count and root to the standard fields.

Full guide: Manifest / batch proofs →

Sealed mode (HMAC commitment, salt private)

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  "salt_b64": "<32-byte salt, base64url>",
  "byte_exact_commitment": "<HMAC-SHA256 of the canonical bytes>",
  "file_size": 208,
  "category": "policy_snapshot"
  // Mirror mode (salt_b64 present): omitting retain_days
  // (the default) keeps the salt-bearing bundle on our disk
  // until you delete the proof — retain_until comes back
  // null. Send retain_days >= 1 for an explicit auto-delete
  // window instead (honored as given on every plan). To keep
  // NOTHING on our disk, use blind mode (omit salt_b64) —
  // see below.
}

Sealed mode — blind submission (salt never reaches us)

Omit salt_b64 to opt into the cryptographically-blind protocol: the salt never crosses the wire, we return the canonical-doc bytes, and your client assembles the .mbnt bundle locally. No server-side bundle is ever written. retain_days must be 0 or absent (nothing to retain).

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  // no salt_b64 — its absence is the blind signal.
  "byte_exact_commitment": "<HMAC-SHA256 of the canonical bytes>",
  "file_size": 208,
  "category": "policy_snapshot"
}

The response carries canonical_b64 + doc_hash in place of bundle_b64; retain_until is omitted. Your client builds manifest.json (with the salt it already holds), drops in the verbatim canonical.json bytes, optionally adds proofs.json rebuilt from local commitments, and zips. The verifier accepts the result transparently — bundle shape is identical regardless of where it was assembled.

Sealed mode — multi-proof / selective disclosure (proof_set)

A sealed anchor can carry the same proof_set envelope as standard mode — same field name, same API symmetry — with HMAC algos inside instead of plain SHA-256. proof_set.byte_exact is required and its commitment must equal the submitted byte_exact_commitment; alongside it you may add content_canonical (a normalized-content HMAC) and/or chunk_merkle (a Merkle-HMAC root over per-chunk leaves under HKDF-derived per-leaf salts — revealing one leaf reveals only that leaf, siblings stay opaque). The companion proof_leaves carries the leaf HMACs.

{
  "mode": "sealed",
  "folder_slug": "agent-runs-prod",
  "salt_b64": "<32-byte salt, base64url>",   // omit for blind
  "byte_exact_commitment": "<HMAC-SHA256(salt, file)>",
  "file_size": 208,
  "proof_set": {
    "byte_exact":        {"algo": "hmac-sha256",
                          "commitment": "<same HMAC as above>"},
    "content_canonical": {"algo": "hmac-sha256",
                          "scheme": "pdf-text-v1",
                          "commitment": "<HMAC of canonicalized text>"},
    "chunk_merkle":      {"algo": "merkle-hmac-sha256",
                          "scheme": "pdf-page-v1",
                          "leaf_count": 12,
                          "root": "<Merkle-HMAC root>"}
  },
  "proof_leaves": {"scheme": "pdf-page-v1",
                   "merkle_leaves": ["<leaf HMAC>", "..."],
                   "metadata": {"leaf_count": 12}}
}

Two differences from standard-mode proof_set: (1) byte_exact carries commitment (HMAC), not hash/size — sealed canonical docs strip leak fields, file_size stays top-level and does not enter the on-chain envelope; (2) algos are hmac-sha256 / merkle-hmac-sha256 — sending sha256 in a sealed body 400s before the wallet is touched. salt_version is server- defaulted (salt_v1); the on-chain envelope binds it so a future version flip stays cleanly partitioned.

Both Mirror (salt_b64 present) and Blind (salt_b64 absent) accept the same envelope. The chunk_merkle leaves ride OFF-chain in the bundle’s proofs.json — the canonical doc carries only {scheme, algo, leaf_count, root, salt_version} — so leaf recompute is structurally impossible at our edge and selective disclosure is the holder’s decision later. If chunk_merkle is present, proof_leaves is required (otherwise the proof would be unverifiable); orphan proof_leaves without a proof_set 400s. There is no force_new 409 here — sealed entries never index a naked file hash, so there is no default-dedup gate to escape.

Response (all modes)

{
  "proof_id": "f83649e3846c4ea2",
  "txid": "2e042a64...7a3db61b",
  "mode": "standard",
  "category": "commitment",
  "retain_until": 1780704000,  // standard mode: bookkeeping only, not an expiry — no automatic deletion is applied to the server copy (kept until you delete the proof). Sealed semantics differ — see notes below
  "dry_run": false,
  "folder_slug": "agent-runs-prod",
  "bundle_url": "https://app.satsignal.cloud/bundle/f83649e3846c4ea2.mbnt",
                                     // GET with the same Authorization: Bearer
                                     // header to download the .mbnt zip
  "proof_url":   "https://app.satsignal.cloud/w/.../r/f83649e3846c4ea2"
                                     // browser web-UI URL (Bearer-authed curl
                                     // → 303 /login). Use bundle_url for
                                     // programmatic clients.
  // manifest mode also: "leaf_count", "root"
  // sealed mirror, retain_days omitted (default): "retain_until"
  //   is null — the server keeps the bundle until you delete the
  //   proof. With an explicit retain_days it is the window's end
  //   epoch.
  // sealed + salt_b64 omitted (blind): "canonical_b64" + "doc_hash"
  //   are returned instead; "retain_until" is omitted entirely.
  //   Your client assembles the .mbnt locally.
}

Quota denials are 429; validation errors 400; missing folder 404 folder_not_found; unknown category 400 invalid_category; unrecognized body field 400 unknown_field.

Reserved fields (will 400)

Two field names that look reasonable are deliberately rejected:

  • dry_run — the broadcast policy is fixed per deploy on this endpoint. Sending dry_run: true will not gate the anchor; we 400 so you notice rather than accidentally creating an anchor while testing.
  • Asymmetry to note: the satsignal CLI and the satsignal-mcp server build the bundle locally before any broadcast attempt, so their dry-run paths don’t touch the chain — but the two shapes differ. The CLI’s --dry-run prints a short text summary of the canonical doc (file, sha256, size, mode, label, out) and writes no sidecar; the MCP server’s dry_run=true returns the would-be bundle as a structured object. The HTTP endpoint is broadcast-only because broadcast-only is its single job. If you want a programmatic dry-run shape, use MCP; if you want a quick visual sanity-check, use the CLI; submit through HTTP when you’re sure.
  • chunk_merkle / content_canonical — not top-level fields. Nest them inside proof_set (see multi-proof / selective disclosure above); sent at the top level they 400 with a hint pointing you there.

Folder-level audit (selective-reveal defense)

A holder disclosing one anchor to an auditor can’t say this is the only one I created on their own word. The folder (the namespace each proof files under) is the unit the operator’s API key controls; an auditor with read scope on that key can list every anchor — current and soft-deleted — the folder has ever contained:

GET https://app.satsignal.cloud/api/v1/folders/<slug>/anchors
Authorization: Bearer <key with proofs:read>

proofs:read is the current scope; keys minted with the legacy receipts:read are equally accepted.

Returns anchor_count plus an anchors[] array (proof_id, txid, mode, category, label, anchored_at, retain_until, deleted, proof_url, bundle_url). Soft-deleted rows are included — chain anchors are permanent and the workspace-side delete must not mask siblings from the audit. Single-proof callers get the same number on GET /api/v1/proofs/<id> as folder_anchor_count.

API-bearer vs browser-session paths. The /proof/<id> route (and its legacy /receipt/<id> alias) is the browser-cookie HTML page — bearer-auth callers get a 303 redirect to /login there (by design, so old email links keep working for logged-in browsers). For programmatic access with an API key, use GET /api/v1/proofs/<id>.

Scope. The audit is folder-scoped. An operator who pre-anchors candidate outcomes under separate folders is not detected by this endpoint; use the workspace-wide listing below for cross-folder audits.

Workspace-wide listing (cross-folder audit)

For audit / compliance scripts asking what did I anchor last week? across every folder:

GET https://app.satsignal.cloud/api/v1/anchors?limit=100
Authorization: Bearer <key with proofs:read>

Most-recent-first. Each row carries the folder-scoped fields plus folder_id / folder_slug / folder_name so a caller can stitch folder context without a second round trip. Soft- deleted rows are included for the same reason as the folder- scoped listing — chain anchors are permanent and the workspace-side delete must not mask siblings.

Pagination. Cursor-based on anchored_at:

{
  "anchor_count": 100,                    // size of THIS page
  "workspace_total": 432,                 // across the whole workspace
  "limit": 100,
  "before": null,
  "next_cursor": 1778196000,              // unix ts; pass as ?before= for next page
  "anchors": [ ... ]
}

Loop until next_cursor is null. limit defaults to 100, capped at 1000.

Filter by session. If you tagged anchors with a session_id at POST time, the listing accepts the same key as a query param to scope an audit to one agent run:

GET https://app.satsignal.cloud/api/v1/anchors?session_id=run-2026-05-09-001
Authorization: Bearer <key with proofs:read>

workspace_total in the response narrows to match the filter, so a caller knows how many anchors are in this session vs the workspace as a whole. Each row carries its own session_id; rows without a session id stay null. Off-chain only — the chain doesn’t see this field. See Agent session proofs for the full pattern.

Quota visibility

Every POST /api/v1/anchors response (200 success and 429 quota denial) carries standard rate-limit headers so callers can back-pressure before hitting a hard stop:

X-RateLimit-Limit:     100        # anchors allowed per window
X-RateLimit-Remaining: 87         # post-anchor on success; pre-anchor on 429
X-RateLimit-Reset:     1780272000 # unix ts of next window reset
X-RateLimit-Window:    month      # 'day' (legacy) or 'month' (free/starter/pro/scale)

The plan quota window is the anchor API’s only key-level throttle — there is no separate per-minute or per-hour burst limit on POST /api/v1/anchors today. Window ceilings: Free 100/month, Starter 10,000/month, Pro 100,000/month, Scale custom. A 429 quota_exceeded carries these same headers (no Retry-After — the window is a fixed calendar window, so wait for X-RateLimit-Reset or mail us for a cap lift). The auth-free /lookup_hash endpoint has its own hour-windowed limits: 120/hour per IP, or 5,000/hour per workspace with a bearer key.

Or fetch the same numbers without creating an anchor:

GET https://app.satsignal.cloud/api/v1/usage
Authorization: Bearer <key with proofs:read>

{
  "plan": "free",
  "window": "month",
  "used": 13,
  "limit": 100,
  "remaining": 87,
  "period_end_utc": 1780272000,
  "anchors_allowed": true,
  "reason": null
}

New signups land on the free plan: 100 anchors per calendar month, monthly reset at the first of the next month UTC. Higher volume (Starter $29 / Pro $99 / Scale custom are the planned tiers) onboards directly today — mail hello@satsignal.cloud to lift your cap; self-serve billing is not yet enabled.

Idempotent retries

Every write endpoint (POST /api/v1/anchors, POST /api/v1/folders, POST /api/v1/webhooks) accepts an optional Idempotency-Key header (Stripe-style; any client-chosen opaque string, ≤ 256 chars). Same key + same request body within 24h returns the verbatim cached response — no second on-chain broadcast, no second quota tick, no duplicate folder / webhook / one-shot signing secret. Same key + a different body returns 409 idempotency_key_reuse_body_mismatch loud instead of silently picking one. Omit the header to opt out (pre-existing behaviour unchanged).

03Reference — Categories

The three agent-shaped categories.

The category field is a tag, not a primitive switch — the chain anchor is the same shape regardless. Tagging the proof lets a verifier say this is a commitment rather than this is some hash.

CategoryModeWhat gets hashed
commitment standard or sealed Canonicalized {nonce_hex, payload} wrapper. The nonce makes the hash unguessable from the payload alone, so anchoring before reveal is safe even if the payload space is small (a score, a vote, a category).
policy_snapshot standard or sealed Canonicalized snapshot JSON. Each component (system prompt, user instruction, tool permissions, budget caps, model config) is hashed independently so an auditor with one component can verify it without seeing the others.
evidence_bundle manifest (always) A list of {label, sha256_hex} leaves. The server combines them into a Merkle tree and anchors the root. Recipients later prove a single leaf with its inclusion path; the other 9,999 stay private.

Three more categories exist for adjacent uses — output (the default; an agent’s produced artifact), memory_checkpoint (a long-running agent’s rolling state hash), and document (a neutral tag for document-shaped anchors that aren’t agent runs at all). Anchoring a contract, report, or evidence file? Send category: "document". Anything not specified above defaults to output.

The complete API enum is exactly these six values: output (the default), commitment, policy_snapshot, evidence_bundle, memory_checkpoint, document — plus one accepted alias, evidence_manifest, which normalizes to evidence_bundle before anchoring. Marketing names like “file proof” are not API values: sending category: "file_proof" returns 400 invalid_category. Omitting the field is still fine — it defaults to output.

04Reference — Selective disclosure of low-entropy rows

Reveal one row of a table; the others stay sealed.

Sealed mode commits to a value now without revealing the payload — reveal it later, or never. Two variants ship: mirror (salt is sent and the server may retain the bundle for recovery) and blind (salt never crosses the wire, the server retains nothing, the client assembles .mbnt locally). For low-entropy row tables — bids, scores, grades, yes/no answers — the merkle-row-sealed-v1 scheme replaces each Merkle leaf with an HMAC under an HKDF-derived per-leaf salt, so a plain SHA-256 leaf can’t be brute-forced and disclosing one row leaks nothing about the others.

Full guide: Reveal one row of a table; the others stay sealed →


Advanced recipes · optional — most integrations don’t need these

·Or anchor spans you’re already emitting

Advanced — optional. Most integrations never touch this; start from the core endpoint and wrappers above.

Anchor selected OpenTelemetry GenAI spans.

If your stack already speaks OTLP — Langfuse, LangSmith, Arize, Datadog, Honeycomb — you don’t need a second integration to anchor what flows through it. Steleet/satsignal-otel is a SpanProcessor you drop into your TracerProvider. Spans you mark with satsignal.anchor=true are batched and anchored as a single manifest proof on BSV; everything else flows through to your existing exporters untouched. Your observability stack shows the run; Satsignal proves the run record hasn’t been edited since.

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from satsignal_otel import SatsignalSpanProcessor, auto_anchor_on_eval_fail

provider = TracerProvider()
provider.add_span_processor(SatsignalSpanProcessor(
    api_key=os.environ["SATSIGNAL_API_KEY"],
    folder_slug="otel-evals",
))
trace.set_tracer_provider(provider)

with trace.get_tracer(__name__).start_as_current_span("eval.scorer") as span:
    span.set_attribute("gen_ai.eval.score", score)
    auto_anchor_on_eval_fail(span, threshold=0.7)

Opt-in per span keeps the anchor surface narrow: only the spans your scorer (or release pipeline, or human review) tags get a proof — not every trace. The headline use case is failed-eval auto-anchor — when a scorer drops below threshold, the failing span anchors with a per- event timing claim. The secondary is release-gate anchor — one proof per deploy that binds prompt version + model + eval pass rate + config hash to the chain.

github.com/Steleet/satsignal-otel · PyPI · worked example: example_otel_eval_run.py. Sha-only by default (your span bytes never leave the process); manifest-batched by default (one BSV transaction per flush window); fail-open by default (anchor failures log + drop, your app keeps running).

·Or anchor objects already in your bucket

Advanced — optional. Most integrations never touch this; start from the core endpoint and wrappers above.

Anchor S3 / R2 / GCS / Azure Blob objects in place.

Your files already live in a bucket. You don’t need to move them or re-upload them to anchor them. Steleet/satsignal-blob walks a prefix, streams the bytes through sha256, anchors the digest on BSV, and writes two sidecars next to each original: contract.pdf.mbnt (the portable evidence bundle) and contract.pdf.proof.json (the one-page summary with txid + proof URL). The original is untouched; file bytes never leave your process.

pip install satsignal-blob[s3]    # or [gcs], [azure], [all]

export SATSIGNAL_API_KEY=sk_...

satsignal-blob anchor s3://my-bucket/contracts/2026/ \
    --folder contracts-2026 \
    --include '*.pdf' \
    --max-files 50

Backed by fsspec, so the same CLI speaks s3:// (AWS, R2, MinIO, Wasabi, B2), gs:// (Google Cloud Storage), az:// (Azure Blob), and local paths. For real-time anchoring on every PUT, wire an S3 ObjectCreated:* event to a Lambda that calls anchor_object — the examples/lambda_handler.py template covers it in 30 lines.

github.com/Steleet/satsignal-blob · PyPI. Sha-only by default (your bucket bytes never leave the process); sidecars are siblings of the original; default-dedup on existing .mbnt sidecar so re-running is safe; fail-open per object so one bad file doesn’t abort the walk. --folder is the current flag; legacy --matter remains accepted.

·Or hand any SaaS a URL

Anchor incoming webhooks from systems you already use.

Paste a Satsignal-provisioned webhook URL into any SaaS that speaks outgoing webhooks — Stripe, GitHub, Langfuse, or a custom signer — and every signed event is canonicalized to its raw bytes, hashed, anchored on-chain, and filed in the folder you pick. No SDK, no per-source integration code. Per-source adapters (stripe, github, langfuse, none) ship as enum values on the API and handle the upstream signature scheme automatically.

Privacy carve-out. Webhook ingest is one of two paths where raw content reaches Satsignal — the other is the plaintext-provenance manifest endpoint; privacy documents both. Here an upstream SaaS POSTs the event to us and we hash it server-side, in memory, to verify the source signature and compute its SHA-256 before anchoring; the body itself is not stored. That differs from the client-hashed flows — file, contract, agent, and batch-manifest proofs — where you hash locally and only the digest ever leaves your systems. Only the fingerprint is anchored on-chain in every case; if the raw body must never leave your infrastructure, hash it yourself and anchor the digest via POST /api/v1/anchors instead.

The non-obvious decision when wiring a webhook is which bytes to hash — raw request body vs. a canonicalized projection. See what bytes do I hash? for the body-normalization rules.

Full guide: Anchor incoming webhooks from systems you already use →

Worked example: AcmeCorp anchors every order webhook →

05Reference — Interop & foreign formats

Advanced — optional. Most integrations never touch this; start from the core endpoint and wrappers above.

Embed a BSV anchor inside an AAR, C2PA, RFC 3161, or TAP proof.

The chain-anchor-v1 scheme is a cross-system / cross-domain Merkle-batching format. One BSV transaction commits to a Merkle root over N leaves; each leaf can be a proof from a different system (an Agent Action Proof, an RFC 3161-style timestamp request, a C2PA-credentialed image, a Visa Trusted Agent Protocol signed request). Each proof carries its own leaf + inclusion proof in an evidenceRef entry, so an auditor with just one proof can independently verify the on-chain commitment without seeing the others.

Five live BSV-mainnet samples cover the canonical interop shapes — AAR batch, dual-attest (RFC 3161 + BSV), cross-domain (Ethereum + HuggingFace + C2PA), C2PA-credentialed image, and Visa TAP — each with full reproduction recipes, real txids, and pure-stdlib verification snippets.

06Reference — Helpers

Drop-in libraries, byte-for-byte across runtimes.

Stdlib only. No Satsignal SDK to install. Each helper produces the same canonical bytes the in-browser verifier expects, so anything you commit on one side reveals cleanly on the other.

PY

commit_reveal.py

Python 3 stdlib only. CLI with commit / reveal / verify subcommands. Use it from an agent runtime, a CI step, or a one-off shell.

Download →

JS

commit-reveal.js

ES module for the browser and Node 18+. Exports makeCommit and verifyReveal. Drop into a frontend, an Edge Function, or a runtime guardrail.

Download →

PY

policy_snapshot.py

Python 3 stdlib only. CLI subcommands hash-component, build, and verify. Selective-disclosure verify (one field at a time) just works.

Download →

PY

agent_anchor.py

Stdlib-only Session() context manager that bundles the four-part agent-session pattern (policy + N commitments + evidence-bundle manifest = N + 2 anchors) into a six-line integration. Writes a handoff.json on exit so an auditor can verify the run offline. See Agent session proofs; deep cookbook at /agents.

Download →

Runnable example. /example_agent_snapshot.py is a 30-line agent that hashes its five policy components, optionally POSTs the snapshot to /api/v1/anchors when SATSIGNAL_API_KEY is set, then takes a (deterministic) action. Replace the decide() stub with a real agent loop and you have an audit-trail-clean configuration anchor in your runtime today. For a fuller pattern (policy + decisions + manifest + handoff JSON in one context manager), see /agent_anchor.py + Agent session proofs.
Language coverage, stated plainly. The full agent-session pattern (policy + N commitments + manifest + handoff.json) ships today as the stdlib-Python Session() in agent_anchor.py. JavaScript ships commit-reveal.js and merkle-row.js — browser/edge/Node 18+ modules for commit-reveal and selective-disclosure verification, byte-for-byte with the Python side. There is no first-class JS Session() yet: TypeScript/JS runtimes anchor today through the language-agnostic MCP server or by POSTing JCS-canonical JSON to /api/v1/anchors from any HTTP client — the wire format is the binding, not the helper. A first-class TS/JS Session() at parity with agent_anchor.py is on the roadmap.

Commit and anchor, end to end

export SATSIGNAL_API_KEY=sk_...   # mint at /w/<workspace>/keys
curl -O https://satsignal.cloud/commit_reveal.py
echo '{"agent_id": "alpha", "score": 73}' | \
  python3 commit_reveal.py commit \
    --payload-json - \
    --out alpha.json \
    --out-anchor anchor_body.json
# alpha.json       — KEEP PRIVATE until reveal (carries the nonce)
# anchor_body.json — ready-to-curl body for /api/v1/anchors

curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
     -H "Content-Type: application/json" \
     -d @anchor_body.json \
     https://app.satsignal.cloud/api/v1/anchors
# Anchor sha256(canonical {nonce_hex, payload}). Do NOT sha256
# alpha.json itself — that file's sha is not the commitment.
07Reference — Verifying without us

From CI, from a browser, from any language.

Every proof ships with everything a verifier needs — the canonical doc, the on-chain transaction id, the chain name, and (for manifest proofs) every leaf with its Merkle path — plus, when present, an informational broadcast acceptance record (unsigned; not used by verification). Verifying is a re-hash and a public block-explorer lookup. Three common shapes:

The audit packet

The audit packet is everything an external auditor needs to re-derive your proof without contacting Satsignal: the canonical bytes, the chain reference, the manifest leaves, and (for sealed-row proofs) the selective-reveal path. Per-proof, this is the contents of the .mbnt bundle plus the proof-page proof-package download (sha256 / HMAC dual-mode, browser-built zip). For a whole workspace, hit the bulk-export endpoint:

GET https://app.satsignal.cloud/api/v1/audit-packet
Authorization: Bearer <key with proofs:read>

Returns a deterministic ZIP archive (Content-Type: application/zip) containing every in-scope proof's canonical bytes, a verification_report.json, the chain reference, and a handoff.json manifest. Same scope + verifier version in → byte-identical bytes out. The X-Audit-Packet-Digest header carries a deterministic packet_digest — the sha256 of the JCS-canonical handoff.json with its own packet_digest field removed (not the sha256 of the zip bytes). Re-derive it offline: unzip handoff.json, drop packet_digest, JCS-canonicalize, then sha256. Optional filters scope the export:

?folder_slug=<slug>   # one folder inside the workspace
?session_id=<s>       # restrict to proofs tagged with this off-chain session id
?since=<unix>         # inclusive lower bound on anchored_at
?until=<unix>         # inclusive upper bound on anchored_at

Proofs whose canonical bytes are unavailable on disk (retention-evicted, etc.) appear in handoff.skipped[] with a reason — never silently dropped. Hyphen-form (/api/v1/audit-packet) is canonical; /api/v1/audit_packet is accepted as an alias. See canonical model § 02 for the typed slots that bind a packet to a run.

In a browser

Drop the bundle.

Open proof.satsignal.cloud/verify and drop the .mbnt. Three pills populate from the proof’s acceptance block and a public block-explorer fetch — no Satsignal API call.

In CI/CD

Re-hash and curl.

Two checks bind the chain to the payload. (1) Re-hash the revealed payload with the helper and confirm it matches the schema-appropriate field inside the bundle’s canonical.json. Server-emitted bundles (the .mbnt you download from bundle_url after POST /api/v1/anchors) are schema_version: 2: use subject.proofs.byte_exact.hash for standard proofs (commitment / output / policy_snapshot / evidence_bundle / file), subject.proofs.byte_exact.commitment for sealed proofs (subject.kind == "file_anchor"), or subject.root for manifest-mode proofs (subject.kind == "manifest"). Older schema_version: 1.1 bundles emitted by some legacy CLI flows use subject.document_sha256 instead — check canonical.json.schema_version to disambiguate. (2) Fetch the OP_RETURN of the proof’s txid from any block explorer’s API, strip the 4-byte MBNT tag, 1-byte version, 1-byte subtype, and 2-byte TLV length (8 bytes / 16 hex chars total), and compare the next 20 bytes (40 hex chars) to manifest.doc_hash_expected — that field is the first 40 hex chars of sha256(canonical.json), which is what the chain actually commits to. Full byte layout is at /spec-mbnt.

By hand

50 lines, any language.

JCS-canonicalize {nonce_hex, payload}. SHA-256 the canonical bytes. Compare to the on-chain hash. The JS helper does it in 50 lines for browser or Node 18+; the Python helper is the same algorithm. For the on-chain payload byte layout, see /spec-mbnt.

Both checks are required. Confirming the txid on the chain without re-hashing the payload only proves an anchor exists at that height; it cannot detect that the file in your hand differs from the one originally anchored. Chain-confirmation alone is not verification — it’s the half that needs (1) to become a tamper-evident claim about this file.
The verifier never calls our API. Proofs record which chain they live on (and, when the broadcaster returned an acceptance record, a display-only label of the accepting endpoint). The browser verifier reads the bundle’s manifest, fetches the transaction from a public explorer, and re-hashes locally. So a saved .mbnt from 2026 still verifies in 2030, even if Satsignal isn’t around — how you ensure you have a saved .mbnt varies by mode.

Programmatic verification — three paths for non-browser integrators

If you’re wiring verification into a CI runner, an agent runtime, or a webhook consumer, you can’t drive a browser. Three paths, in increasing order of "I already have Python":

Path A — hosted verifier or the stdlib helper (no install)

# Hosted: drag-drop the .mbnt at proof.satsignal.cloud/verify (account-free)
# Or, stdlib-only, no pip install:
python agent_anchor.py verify-handoff             # exit 0 = pass

The hosted verifier and the scripts/agent_anchor.py helper run the same checks the browser verifier runs — bundle sha-consistency, canonical-sha extraction, and a chain-confirm that resolves the anchored sha to its txid via Satsignal’s public /lookup_hash oracle (default app.satsignal.cloud; override with --lookup-base-url, or skip with --no-chain-confirm). Pure-Python, no API key required. The one-command satsignal verify shipped in satsignal-cli 0.5.0.

Path B — inspect the .mbnt by hand (bash + jq)

A .mbnt is a zip with two files (manifest.json and canonical.json):

unzip -q proof.mbnt -d /tmp/bundle/

# 1. Bundle’s claimed file hash matches your local file?
# Path depends on canonical.json's schema_version:
#   v2 → subject.proofs.byte_exact.hash
#   v1 → subject.document_sha256   (older bundles emitted by some flows)
jq -r 'if .schema_version >= 2 then .subject.proofs.byte_exact.hash else .subject.document_sha256 end' /tmp/bundle/canonical.json
sha256sum event.json
# both should print the same 64-hex string

# 2. Pull the on-chain commitment.
jq -r '.txid' /tmp/bundle/manifest.json
# look up this txid on any BSV explorer; OP_RETURN binds to canonical sha

# Optional: broadcast acceptance record (informational). manifest.json
# carries an .acceptance block ONLY when the broadcaster returned a
# network-validated status (SEEN_ON_NETWORK, ACCEPTED_BY_NETWORK, ...).
# Unsigned strings; not load-bearing for verification. Freshly minted
# bundles typically omit it — absence is normal, not a failure.
jq -r '.acceptance.status // "no acceptance block (normal)"' /tmp/bundle/manifest.json

If subject.proofs.byte_exact.hash equals the sha256 of your local file, and the txid resolves to an OP_RETURN whose payload binds to sha256(canonical.json), you have everything the hosted verifier checks — or run satsignal verify (satsignal-cli 0.5.0) for the one-command path. For the on-chain payload byte layout, see /spec-mbnt.

What the OP_RETURN actually looks like, byte by byte. A worked sample from a real mainnet anchor (txid 05aac3a419328aee45404a4a11034b76bbc043c0b891d59faca94b7f35b0e218) — the raw scriptPubKey of its OP_RETURN output is 37 bytes:

00 6a 22       # OP_FALSE, OP_RETURN, push 0x22 (34 payload bytes)
4d 42 4e 54    # protocol tag — ASCII "MBNT"
01 01          # version 0x01, subtype 0x01 (generic)
00 06          # TLV section length = 6 bytes
01 e6 29 9c 3b 1d 69 7a 84 d6 b4 92 a0 30 6e 14 36 8a 98 59
               # 20-byte doc_hash = sha256(canonical.json)[:20]
               #   — equals manifest.json's doc_hash_expected
05 04 d5 b0 b0 c6
               # TLV: tag 0x05 issuer_id, len 4 (operator DID fingerprint)

Prefix shape varies by data source. The raw on-chain script always starts 00 6a (OP_FALSE OP_RETURN) — that is what the raw tx hex and Bitails return. WhatsOnChain’s JSON API strips the leading 00 from scriptPubKey.hex, so the same output reads 6a224d424e54… there. Strip one optional leading 00 byte before parsing and both sources work. Full byte layout and parser rules: /spec-mbnt §1–§2.

manifest.json, field by field. Every standard bundle carries txid, network, and doc_hash_expected (first 20 bytes of sha256(canonical.json), hex-encoded). Standard bundles minted before 2026-06-11 also carry proof_mode — a legacy dispatch hint whose value is always private_receipt; newer standard bundles omit it. The name predates sealed mode and is not a privacy indicator; sealed bundles never carried it and use mode: "sealed" (plus salt_b64 / salt_version) instead. Verifiers must not switch on proof_mode — dispatch on mode — and must tolerate both its presence (older bundles, forever) and its absence. Optional fields: category (present only when non-default) and acceptance (the informational broadcast acceptance record, above). There is no broadcaster field and no confirmations field anywhere in the bundle — the manifest is a static file written once at anchor time; confirmation depth lives on the public chain. Full field table: /spec-bundle §3.

Path C — /lookup_hash for hash-existence (auth-free)

If all you need is "has this exact file ever been anchored on this server?", the public lookup endpoint answers in one curl — no key, no bundle:

SHA=$(sha256sum event.json | awk '{print $1}')
curl -sS "https://app.satsignal.cloud/lookup_hash?sha=$SHA"
# Hit:  {"proof_id":"...","created_utc":"...","txid":"..."}
# Miss: {"miss":true,"reason":"sha_not_indexed_as_file_hash"}

miss on an anchor you know exists? If the anchor was sealed (mode=sealed), /lookup_hash will always return miss with reason: sha_not_indexed_as_file_hash. Sealed anchors commit to an HMAC of the document, not the file sha, so they're intentionally excluded from this endpoint. Use the /verify browser UI (which takes the document + salt) to confirm a sealed anchor, or see the sealed integration guide for the full flow.

The inverse cuts too: Standard-mode hashes are publicly discoverable. Anyone who holds (or can reconstruct) the exact file bytes can compute the sha256 and ask /lookup_hash whether this server anchored it — no account needed. If the existence of an anchor is itself sensitive (a source document, a privileged draft), anchor it in sealed mode: sealed anchors commit to an HMAC and are excluded from this endpoint by design.

Accepts both ?sha= and ?sha256_hex=; conflicting values return 400 conflicting_alias. Rate-limited 120/hour per IP; CORS-open so third-party verifier UIs can call from any origin without proxying.

RECEIVED vs CONFIRMED — and why fresh anchors 404 on public explorers

Right after POST /api/v1/anchors returns, the transaction has been accepted by the BSV broadcaster (3-tier failover across independent broadcast services) — the broadcast-lifecycle state broadcasters call RECEIVED. That state is not a manifest field: the bundle’s manifest.json is a static file written once at anchor time and never updates. Broadcast acceptance also does not yet mean a public explorer has indexed the tx. WhatsOnChain typically picks it up within 1–5 minutes of broadcast, occasionally longer (~10); during that window:

  • api.whatsonchain.com/v1/bsv/main/tx/hash/<txid> may return 404 — expected, not a failure.
  • api.bitails.io typically indexes faster — useful as a fallback while WoC catches up.
  • The bundle is fully verifiable offline already — you do not need an explorer hit to prove the anchor exists.

CONFIRMED means a block has been mined containing the transaction (typically ~10 min on BSV under normal load). Confirmation depth is a property of the public chain, not of the bundle — poll any explorer for it. A CI gate that waits for depth ≥ 6:

TXID=$(jq -r '.txid' /tmp/bundle/manifest.json)
until [ "$(curl -s "https://api.whatsonchain.com/v1/bsv/main/tx/hash/$TXID" \
           | jq -r '.confirmations // 0')" -ge 6 ]; do sleep 60; done

Treat RECEIVED as pending, not failed. The by-hand check (Paths A–B) already passes for an indexed-but-unconfirmed transaction — it does not gate on depth. To require a minimum confirmation depth today, wrap it in the curl+jq CI loop shown above (depth ≥ N). A standalone satsignal verify CLI with a --min-confirmations flag (defaulting to 0) is planned but not yet shipped. In the first minutes after broadcast, before explorers have indexed the txid, an explorer lookup returns non-zero regardless — have CI wait and retry rather than fail the build.

Durability across modes — what the auditor needs at verify time

"Verify forever" assumes the auditor has the .mbnt bytes in hand. How the holder gets those bytes differs across modes — and for one mode, that's the holder's job from anchor time onward, not an automatic guarantee:

mode how the auditor gets the bundle bytes verify works without Satsignal?
Standard
commitment / output / policy_snapshot / evidence_bundle / file proof
Anchor response carries bundle_url. Holder MUST GET it (with bearer auth) and persist locally — the server copy is kept until you delete the proof (the numeric retain_until on standard responses is bookkeeping, not an enforced expiry). After you delete it, the server-side copy is gone. Yes while the server copy lives (until you delete the proof) — or from your own downloaded copy after that. Otherwise the chain anchor still exists, but binding txid back to a specific submitted hash needs Satsignal-hosted /lookup_hash.
Sealed — Mirror
salt_b64 sent
bundle_url + bearer auth. By default the server copy is kept until you delete the proof (retain_until: null); an explicit retain_days window auto-deletes it at retain_until. Bundle is salted; selective reveals happen via /unseal. Yes while the server copy lives (indefinitely by default) — or from your own copy after an explicit window lapses.
Sealed — Blind
salt_b64 omitted
Anchor response carries canonical_b64 + doc_hash; your client assembles the .mbnt locally. Server keeps NO copy. Holder MUST persist at anchor time. Yes, unconditionally — server has nothing to lose; the holder is sole custodian from anchor time onward.

Download your .mbnt at anchor time. By default the server-side copy is kept until you delete the proof — on every plan, for standard and sealed-mirror bundles alike. A sealed-mirror anchor may instead set an explicit retain_days window; a server sweep then deletes that bundle when the window expires — and your own downloaded copy never depends on ours. See the DPA for the full retention statement. On sealed anchors the response’s retain_until is authoritative (null = kept until you delete; a number = the enforced window’s end); on standard anchors the numeric value is bookkeeping — no automatic expiry is applied. The chain anchor is permanent regardless, but if the server-side copy is ever gone — you deleted the proof, or an explicit retain_days window lapsed — re-binding the txid to your hash depends on Satsignal-hosted /lookup_hash. One GET bundle_url at anchor time removes that dependency forever.

The asymmetry: in Standard and Sealed-mirrored modes, never downloading means that if the server copy is ever gone (you delete the proof, or an explicit retain_days window lapses) the chain anchor is permanent but binding txid back to your payload’s hash relies on /lookup_hash (a Satsignal-hosted service). Persist the .mbnt at anchor time and that dependency goes away.

08Reference — Proof schema

The bundle, in plain JSON.

Each proof is a .mbnt file — a small bundle containing the canonical-doc JSON and the manifest (txid, network, expected doc hash — plus the optional informational broadcast acceptance record when the broadcaster returned a network-validated status). Download a sample to inspect:

Each bundle is a regular ZIP — rename .mbnt to .zip and inspect the contents. Field-by-field reference is in the in-browser verifier’s side panel; drop a bundle and toggle “Show raw JSON” to see every field rendered against its source.

09Reference — Annotating an anchor

Off-chain “this was a typo” without rewriting history.

The chain is permanent. If you anchored the wrong payload, or a later run supersedes an earlier one, you can’t edit the transaction. What you can do is attach an operator annotation to the workspace proof. The chain anchor stays untouched; the annotation surfaces on the dashboard proof page and on any share link you mint, with explicit workspace metadata, not chain mutation framing so an auditor isn’t misled into thinking the original anchor was revoked.

Verifiers ignore annotations entirely — they consume the bundle, not the workspace’s customer DB.

Endpoint

PATCH https://app.satsignal.cloud/api/v1/anchors/<proof_id>
Authorization: Bearer sk_...
Content-Type: application/json

{
  "annotation": {
    "kind": "typo",                       // typo | superseded | redacted | note
    "text": "off-by-one in the label"     // optional, ≤ 1024 chars
  }
}

Required scope: proofs:annotate (legacy receipts:annotate equally accepted). Off the default-mint set so a stolen anchor-only key cannot retroactively flag proofs; opt in explicitly when minting a key for an annotation tool.

Kinds

KindWhen to use it
typo You anchored a payload that has a mistake in it but is not sensitive. The annotation explains the error so an auditor reviewing the folder understands the anchor should not be relied on.
superseded A later anchor in the same workspace replaces this one. Pass the replacement proof id in the legacy superseded_by_bundle_id field; the proof page links across. Same-workspace only; cross-workspace pointers are rejected.
redacted The bundle’s context (label, memo, filename) is now flagged as sensitive or wrong. The on-chain hash stays public; the workspace surfaces a redaction notice on the proof page. (You cannot retract chain data; this is a workspace-side disclosure that the anchor should not be referenced.)
note Catch-all for non-status context (a citation, a cross-reference, an operator’s comment). No behavioral implication.

Supersede shape

PATCH /api/v1/anchors/<proof_id>
{
  "annotation": {
    "kind": "superseded",
    "superseded_by_bundle_id": "<replacement proof id>",   // legacy field name; value is the replacement proof id
    "text": "replaced by run #42 after the metric revision"
  }
}

Clearing an annotation

PATCH /api/v1/anchors/<proof_id>
{ "annotation": null }

Both set and clear write a row to audit_events under the internal event names proof.annotation_set / proof.annotation_cleared, with the API key prefix, the prior kind, and (for set) text length. The dashboard activity feed surfaces the trail so a renamed-or-revoked annotation is not silent.

Response

Returns the same JSON shape as GET /api/v1/proofs/<proof_id>, with an annotation field (the new state) when one is set. The field is omitted when the annotation has been cleared.

Errors: 401 missing_bearer / 403 insufficient_scope (key lacks the annotate scope; the wire message names proofs:annotate) / 404 anchor_not_found (no proof with that id in the key’s workspace) / 400 annotation_failed (unknown kind, oversize text, missing supersede target, self-supersede, superseded_by_bundle_id on a non-superseded kind).

10Troubleshooting

When a check doesn’t pass.

This section covers common integration friction points. For a full pre-flight checklist before going live, see the Production checklist — covers idempotency wiring, key rotation, retry policy, broadcast failure recovery, and the verification UX.

Every failure below is diagnosable client-side — the verifier and the public chain are the source of truth, not us.

The proof won’t verify.

Open the in-browser verifier and drop the bundle on its own first. If the proof structure and the on-chain anchor both pass and the match step is the only failure, the bundle is intact — the item you supplied isn’t the one that was anchored. Re-check you uploaded the exact original, not a re-export or a re-encoded copy.

Hash mismatch on the original.

The fingerprint is over the exact bytes. A file that was re-saved, re-compressed, line-ending-converted, or had metadata rewritten is a different byte stream and a different SHA-256. Verify against the artifact as it was at anchor time; if it passed through a pipeline, anchor the post-pipeline bytes.

Lost the sealed bundle.

A Sealed proof can’t be re-derived without the bundle — that privacy is the point. The on-chain commitment still proves timing, but the match step needs the bundle (and, for sealed rows, the per-leaf salt). Treat the bundle as the artifact of record: store it the way you’d store a signed contract. See selective disclosure for what each salt unlocks.

Chain lookup fails or shows nothing.

A just-anchored transaction can take a few minutes to surface on a public explorer; the proof is valid before the explorer indexes it. Resolve the commitment SHA without auth at /lookup_hash — if it resolves to a bundle and txid there but an explorer still 404s, the explorer is lagging, not the anchor. Still stuck after the transaction confirms? Email us the proof ID.

11How we publish

How we publish.

Every Satsignal package on PyPI — satsignal-cli, satsignal-mcp, satsignal-otel — is published by GitHub Actions via PyPI Trusted Publishers. There is no long-lived API token on a maintainer’s machine, no ~/.pypirc, no shared team secret. PyPI accepts each upload because GitHub mints a short-lived OIDC token from a specific workflow file (publish.yml) in a specific environment (pypi) in a specific repo — and PyPI was told in advance exactly which workflow it will accept from. Revocation is a single action in the PyPI UI; there is no token-rotation choreography because there is no token.

The release trigger is gh release create. A separate workflow anchors each release tag to BSV through Satsignal itself, giving the release a tamper-evident timing claim independent of GitHub.

Each upload also carries a PEP 740 Sigstore attestation that binds the artifact to the exact GitHub Actions workflow run that produced it. This has been live and machine-verifiable on satsignal-mcp since 0.4.1. The verification path: fetch the PEP 691 simple-index JSON at https://pypi.org/simple/satsignal-mcp/ (with Accept: application/vnd.pypi.simple.v1+json), read the files[].provenance URL, then fetch the PEP 740 integrity endpoint at https://pypi.org/integrity/<pkg>/<ver>/<file>/provenance. The response is a Sigstore attestation bundle whose certificate SAN binds the artifact to publish.yml at the tagged commit and whose Rekor entry transparency-logs the signature.

So both guarantees — OIDC publish (no maintainer token) and Sigstore attestation (independently-verifiable build provenance) — are live and verifiable today on the machine-readable surface. The human-readable badge on pypi.org/project/<pkg>/<ver>/ is not yet rendered; that’s a separate PyPI UI rollout and does not block today’s verification. The legacy warehouse JSON at pypi.org/pypi/<pkg>/<ver>/json predates PEP 740 and still reports provenance: null — it is not the canonical attestation surface.

The byte-level reference — workflow file path, the PyPI configuration, what an integrator can verify — lives in the pilot RELEASE.md in Steleet/satsignal-mcp.

12Next steps

Get an API key, anchor your first commit.

Sign in to mint a key, create a folder, and anchor a proof against your own runtime. The free plan covers a full integration and verify cycle — upgrade to Starter when 100 anchors a month stops being enough.

Reference: Production checklist · Compatibility map · What to hash

Worked examples: AcmeCorp webhooks · ResearchAgent runtime

Implementer specifications

Building an independent verifier or integrating at the protocol level? These are the byte-level references — you don’t need them to use the API above.