Building with Satsignal from mobile apps and edge runtimes

Terminology. The canonical vocabulary on every surface is proof (grouped in folders); API responses emit the canonical names only (proof_id, proof_url, folder_slug). Where this spec shows receipt / matter / bundle_id, it is documenting a frozen on-disk/on-chain format, a stable filename (RECEIPTS.md), or a legacy route that remains accepted inbound — proof and receipt denote the same record. Full alias map: the compatibility map.

What you can call from a browser, a Cloudflare Worker, a Deno Deploy isolate, or a phone webview — and what has to stay on the server side.

For the underlying canonical-doc shape and on-chain wire format, read /spec. For agent-session integration patterns (policy snapshot, decision commitments, manifest), see the agent-session proofs section in the docs.


TL;DR

[mobile UI / edge runtime]
        |  read-side: any of the browser-callable surfaces below
        |
        |  anchor-side: forward to your own backend
        v
[your backend] --(API key)--> [proof.satsignal.cloud/api/v1/anchors]

The read side (lookups, verification, embedded badges, deep links into the verifier) runs entirely from a browser or edge runtime. Anchoring runs through your backend, because the API key MUST NOT be exposed to client JavaScript.


The four browser-callable surfaces

All four are CORS-friendly and need no API key.

1. GET /lookup_hash?sha=<64-hex> — "is this sha256 anchored on BSV?" Public, read-only, rate-limited per IP. Returns {"proof_id":"...","created_utc":"...","txid":"..."} on hit, and the typed miss envelope {"miss":true,"reason":"sha_not_indexed_as_file_hash"} on miss (HTTP 200 either way). Access-Control-Allow-Origin: * on every response (including errors), so a fetch() from any origin works without a proxy.

const r = await fetch(
  "https://proof.satsignal.cloud/lookup_hash?sha=" + sha256hex);
const j = await r.json();
// Branch on a truthy j.txid: set on a hit, absent on the typed miss
// ({miss:true, reason:...}).

/lookup_hash is an existence oracle keyed on sha256(file_bytes) (or, for merkle-row schemes, on the commit-doc sha). It does NOT resolve sealed-mode anchors (excluded by design — see SPEC_v2_sealed.md §7.2) or manifest-items-v1-style multi-file bundles (no single naked sha to key on — verify those by holding the bundle and chain-confirming the txid).

A small number of historical anchors MAY be retracted by the operator (e.g. an accidental upload, a PII-bearing canonical). A retracted sha responds with a typed miss on /lookup_hash. Verifier implementers MUST treat that miss as "this oracle no longer asserts existence for this sha" — it is not equivalent to a non-existence proof. See "lookup_hash retraction" below for the exact envelope and the asymmetry.

2. https://proof.satsignal.cloud/widget.js — drop-in "anchored on BSV" badge for any third-party page.

<div class="satsignal-verify"
     data-sha="<64-hex sha256>"
     data-bundle-url="<optional .mbnt url>"></div>
<script src="https://proof.satsignal.cloud/widget.js" async></script>

Renders a green/gray/amber chip with a deep link out to /verify. Idempotent and async-safe. Embedder's CSP needs to allow script-src https://proof.satsignal.cloud and connect-src https://proof.satsignal.cloud.

3. GET /verify?bundle_b64=<base64> and ?bundle_url=<url> — deep-link auto-load on the public verifier page. bundle_b64 is pure client-side (no fetch); bundle_url performs a same-origin fetch (the page's CSP connect-src 'self' only allows proof.satsignal.cloud URLs, which is what you want for /bundle/<id>.mbnt and /static/*.mbnt deep links).

4. The canonicalization helpers at the apexsatsignal.cloud/commit-reveal.js and satsignal.cloud/merkle-row.js. Vanilla ES modules, no dependencies. They produce the byte-exact canonical form that the chain anchored, so a client can compute a local hash and compare it to a /lookup_hash answer without ever trusting a server with the plaintext.


lookup_hash retraction

The /lookup_hash endpoint is an existence oracle backed by a mutable server-side index. Most anchors stay indexed for the life of the chain. A small number MAY be retracted by the operator — typically to honor a takedown, to remove an accidental upload, or to purge a sha that turned out to be PII-bearing. This section defines the semantics callers and verifier implementers MUST assume.

What retraction is (caller-visible semantics)

A retraction changes the answer the operator's /lookup_hash index returns for a given sha. It does not change anything on chain. The original anchoring transaction remains broadcast, mined, and permanent; nothing the operator does can revoke that. What changes is the operator's willingness to assert the existence/identity mapping between the sha and a particular bundle through this oracle.

After a retraction lands for sha H:

The sentinel sweep (mechanic)

The retraction primitive does two things, atomically from the caller's perspective:

  1. Index sweep. The server-side hash index is a scan over bundle sidecars keyed on file_sha256_hex. The resolver is oldest-first (an earliest-anchor-wins policy — see commit 296030f, 2026-05-14): a single matching sidecar is therefore not enough to take a sha out of the index, because an older sidecar carrying the same sha would still resolve. Retraction MUST sweep every sidecar carrying the target sha and clear the file_sha256_hex field on each.
  2. Dedup-store null. Schema v7 introduced a second store of the same fact — proofs.sha256_hex (the receipts table was renamed proofs at schema v14) — used as the cross-process default-dedup key. Retraction MUST null this column for every affected bundle, or a subsequent default-dedup POST of the same content would dedup to the retracted proof and effectively un-retract it.

The cleared / nulled fields are the sentinel values the resolver keys on, hence the operational name sentinel sweep. The sweep is distinct from — and MUST NOT be conflated with — the sealed-mode TTL cleanup described in SPEC_v2_sealed.md §7.1, which deletes sealed sidecars wholesale after a retention window expires. The retraction sweep is targeted (one sha, all matching sidecars), edits in place, and applies to standard-mode anchors; the sealed-TTL sweep is bulk, deletes whole sidecars, and applies only to sealed-mode anchors.

The sweep does NOT need a service restart. Resolution reads sidecar JSON fresh per request, and the dedup query reads the proofs row live.

API surface

Retraction is operator-only. It is intentionally not exposed over HTTP — there is no POST /retract, no API-key authorization for it, no browser-callable form. The procedure runs out-of-band on the operator's host with filesystem and database access. Integrators MUST NOT design flows that depend on programmatic retraction; if your application needs takedown plumbing, contact the operator.

The canonical implementation is scripts/retract_sha.py in this repository. It runs dry-by-default, prints the matching sidecars, and only mutates state when invoked with --apply. The script is the single procedure that keeps the sidecar index and the dedup store consistent; ad-hoc edits to one without the other MUST NOT be used.

Guarantees and non-guarantees

What a post-retraction /lookup_hash miss DOES mean:

What it explicitly DOES NOT mean:

This asymmetry — /lookup_hash can affirm existence but cannot prove non-existence — is the load-bearing fact for any verifier implementation that wants to reason about retraction.

Cross-references


Anchoring is server-side only

POST /api/v1/anchors is intentionally NOT CORS-enabled. A browser preflight against it returns 501 — that is by design, not a bug.

The reason: anchoring requires a Bearer API key (Authorization: sk_live_...). If you opened CORS to the anchor endpoint, integrators would inevitably embed the key in client JavaScript, which would leak it to every visitor's devtools and network log. The 501 forces the right architecture:

[mobile app]
    |  POST /your/own/anchor with the user's payload
    v
[your backend] -- holds the API key, calls --> /api/v1/anchors
    |                                          (returns proof + txid)
    v
[mobile app] <-- relays the proof back to the client

Your backend can be tiny — a serverless function, an edge worker with the API key in a secret binding, or the API server you already have.

If you find yourself wanting to call /api/v1/anchors from a browser because "it would just be easier," stop. That convenience is exactly the leak the 501 prevents.


Web Crypto and secure contexts

The canonicalization helpers (commit-reveal.js, merkle-row.js) use crypto.subtle.digest for SHA-256. That requires a secure context — HTTPS or localhost. On an HTTP origin, the helpers throw:

crypto.subtle not available — need a secure context (https://)

What this means in practice:


A complete .mbnt bundle is small. The standard demo is 836 bytes; the sealed demo is 922. After base64 encoding, that fits comfortably inside a single QR code.

QR-encode a bundle for in-person verification:

import base64, qrcode
with open("proof.mbnt", "rb") as f:
    b64 = base64.urlsafe_b64encode(f.read()).rstrip(b"=").decode()
url = "https://proof.satsignal.cloud/verify?bundle_b64=" + b64
qrcode.make(url).save("proof-qr.png")

A scanner opens the URL, the verifier page decodes the bundle in JavaScript, runs the full pill verification, and renders the result. No network round-trip to fetch the bundle.

Deep-link to a hosted bundle when you don't want to embed the bytes in the URL:

https://proof.satsignal.cloud/verify?bundle_url=https://proof.satsignal.cloud/bundle/<id>.mbnt

Both query params auto-trigger the verify flow; the visitor lands on a fully-rendered result panel without clicking through a file picker.


The widget

For a regulator portal, a journalist's microsite, an internal compliance dashboard, or any other third-party page that just wants to show "this hash is anchored on BSV" inline, drop in the widget:

<p>Proof for invoice #2024-1138:</p>
<div class="satsignal-verify"
     data-sha="b0205959420906f936c32d3faad859f92f48110f5a25033fca6599ca56b16b31"
     data-bundle-url="https://proof.satsignal.cloud/bundle/8018ba7ecf914d8e.mbnt"></div>
<script src="https://proof.satsignal.cloud/widget.js" async></script>

The widget reads data-sha, calls /lookup_hash, and renders one of:

The verify link opens /verify in a new tab. If you supplied data-bundle-url, the link is the deep-link form (/verify?bundle_url=...) so the visitor lands on a fully-rendered verification panel.

For inline full pill verification (rather than just the anchored status badge), embed /verify itself in an iframe — but note that proof.satsignal.cloud sets frame-ancestors 'none', so iframing is currently blocked. If you need this, file an issue describing the embedding origin and we'll talk through what a relaxed CSP for an embed-specific path would look like.


What is NOT browser-callable

Some write and admin surfaces are intentionally server-side or same-origin only. Browser and edge integrations should use the lookup endpoint, the widget, verifier deep links, and the client-side canonicalization helpers; anchoring should be proxied through your backend, never called from client JS.

The default is closed because each opening is a security review, not a config tweak.

Questions about this specification? Email hello@satsignal.cloud.