Files / Direct API — anchor a file or report
The straight-line path. You have a file (a report, an export, a contract, a JSON event, a CSV) and you want a Bitcoin-anchored proof that you held those exact bytes at a specific time. This guide walks the bearer-auth JSON API end-to-end: create a folder, hash locally, anchor the hash, store the fields you need for later verification. Roughly ten minutes from a blank shell to a verified
.mbntbundle.
Companion docs: API reference · OpenAPI spec · What to hash · Production checklist · Compatibility map
Naming. The canonical vocabulary is
proof/folder; responses emit canonical field names only, and requests written against legacy spellings keep working forever. Full alias map: Compatibility map.
1. The 60-second framing
You hash the file on your machine. The file itself never leaves you. Satsignal anchors a 20-byte commitment to that hash in an OP_RETURN output of one BSV transaction. You get back a proof_id, a txid, and a .mbnt bundle that lets anyone (no account, no API key) confirm later that the file existed in bit-identical form by the block time. Five seconds with a coffee in your other hand — the only moving pieces are an HTTPS POST and a SHA-256.
Concrete: you generate an eval report at 14:30 UTC, hash it, hit /api/v1/anchors. By 14:31 UTC there is a public BSV transaction whose payload commits to your report's hash. A counterparty six months later can verify the original file matches that exact transaction, with no need to ping us.
2. Use this when
- You have a file or a small report and you want one shot — no streaming, no batching, no per-event integration code.
- You're scripting against the API from
bash/curl/python/ any HTTP client and you want explicit control over what gets sent. - You're embedding anchoring inside a custom flow (a desktop app, a backend job, a notebook) where the SDK shape doesn't fit.
- The artifact is a single canonical document — a PDF, a JSON export, a CSV, a build manifest, a policy snapshot — and you have it on disk before the anchor call.
- You want to keep canonical artifacts under your control; the chain carries a hash commitment, you carry the bytes.
- You don't need source-specific webhook plumbing (see Webhooks) or multi-item batching (see Manifest).
3. What you send
The wire endpoint is POST /api/v1/anchors on https://app.satsignal.cloud. Bearer auth (Authorization: Bearer sk_...). JSON body. Mode (standard / sealed / manifest) is inferred from body shape — manifest indicates a multi-item batch (an evidence shape), not a privacy mode. For a plain file anchor it's standard mode, the default.
| field | type | required | meaning |
|---|---|---|---|
folder_slug | string | yes | the folder this proof files under. Created once with POST /api/v1/folders. |
sha256_hex | string | yes | 64 lowercase hex chars — sha256(file_bytes). |
file_size | integer | yes | byte length of the source file. |
category | string | no | one of commitment, policy_snapshot, evidence_bundle, memory_checkpoint, document, output; omitted/empty defaults to output. Anchoring a contract, report, or evidence file? Use document. Closed enum (anything else is 400 invalid_category); one alias is accepted, evidence_manifest → evidence_bundle. |
label | string | no | free-text tag (display only). |
filename | string | no | source filename. Display-only; does not enter the canonical doc's hash. |
session_id | string | no | off-chain label for grouping. Useful for agent runs. |
force_new | bool | no | bypass the same-hash dedup gate. Default false. |
Idempotency-Key (header) | string | no | a stable per-request key. 24h window, body-hash must match on replay. See §7. |
For a commitment anchor the category is the only thing that varies substantively across calls; the shape stays the same.
Standard-mode hashes are publicly discoverable. Anyone who holds (or can reconstruct) the exact file bytes can compute the sha256 and ask the auth-free
/lookup_hashendpoint 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 instead: sealed anchors commit to an HMAC and are excluded from that endpoint by design.
Folder creation (one-time)
A folder is the namespace each proof files under. One per integration is fine.
curl -X POST https://app.satsignal.cloud/api/v1/folders \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"AcmeCorp Events Dev","slug":"acme-events-dev"}'
New code should use /api/v1/folders; the legacy route remains accepted (Compatibility map).
4. What you store
After a successful anchor you persist these in your own system, alongside the original artifact. Without the original bytes the proof can still be checked against the chain — but it can't be tied back to your data.
proof_id— your stable handle. Use it to fetch the bundle, show a proof page, or include in audit logs. Forever-stable.txid— the BSV transaction id that carries the OP_RETURN commitment. The only thing you strictly need to re-derive the proof from a block explorer.bundle_url— the absolute URL of the portable.mbntbundle. Bearer-auth gated. Persist it; downloading once and caching locally is the standard pattern.proof_url— dashboard page for humans. It is sign-in gated: an unauthenticated GET 303-redirects to/login, so it is not a credential-free share link. To let a counterparty verify without an account, hand them the.mbntbundle (plus the original artifact) and point them at the public/verifypage — account-free, drag-drop the.mbnt.sha256_hex— the hash you submitted. Keep it so you can re-verify without having to recompute the hash from the artifact every time.folder_slug— your grouping key. Stable; useful for later inventory queries viaGET /api/v1/anchors?folder_slug=....label(if you sent one) — your free-text tag.- The original canonical artifact — the exact bytes whose SHA-256 you submitted. If you canonicalized to a normalized form (CSV row order, JSON key sort, text line endings), persist the canonical bytes, not just the source. See What to hash for why this is the most-common verification-time mistake.
Database row shape (reference)
A minimal SQL-ish schema for an integrator's local store:
| column | type | notes |
|---|---|---|
proof_id | text PK | from response |
txid | text | from response |
sha256_hex | text | what you submitted |
file_size | int | what you submitted |
folder_slug | text | namespace |
label | text | optional tag |
bundle_url | text | absolute URL |
artifact_path | text | where the original bytes live in your storage |
anchored_at_utc | timestamp | response accepted_at_utc if you read the manifest |
That's it. The on-chain transaction is the source of truth for the proof itself; the row above is your local index into that.
5. What verification needs later
Three things, every time:
- The
.mbntbundle. Self-contained — carriesmanifest.json,canonical.json, optionalproofs.json. The portable proof. - The original artifact. The bytes whose SHA-256 was anchored. For a file proof the verifier re-hashes the file and compares to the canonical doc's
byte_exact.hash. Without the artifact, the chain side still verifies but you can't tie that on-chain commitment to your specific file. - A way to read a BSV transaction. Any public BSV node — no Satsignal-operated endpoint required, no API key required. The verifier resolves the txid, parses the OP_RETURN payload, and compares the embedded
doc_hashtosha256(SCJ_v1(canonical.json))[:20](SCJ-v1 = sorted keys, compact, NFC — deliberately NOT RFC 8785/JCS).
The web verifier at /verify does all three steps (drag-drop the .mbnt, account-free); for a scriptable check, the unzip + sha256sum recipe in §11 does the same from a shell, resolving the txid against any explorer or a node you run. (A standalone satsignal verify CLI, with --spv / --min-confirmations, is planned but not yet shipped.) Cross-link: What to hash covers what counts as "the bytes you hashed" for each artifact shape — files, JSON events, webhook bodies, agent decisions.
For CI runners, agent runtimes, and other non-browser integrators that can't drive the web verifier: §11 covers the programmatic verify paths (the manual unzip + sha256sum recipe and the auth-free /lookup_hash endpoint), plus the RECEIVED vs CONFIRMED semantics for what to do while public explorers catch up with a freshly-broadcast transaction.
6. Copy-paste example
The runnable five-step shell flow. Replace sk_... with your key from app.satsignal.cloud.
export SATSIGNAL_API_KEY=sk_...
export FOLDER=acme-events-dev
# 1. Create a folder (one-time per project / matter / engagement)
curl -X POST https://app.satsignal.cloud/api/v1/folders \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"AcmeCorp Events Dev","slug":"acme-events-dev"}'
# 2. Write the artifact you want to prove you held
echo '{"event":"order.completed","id":"evt_demo_123","ts":"2026-05-26T00:00:00Z"}' > event.json
# 3. Hash it locally — the bytes never leave your machine
HASH=$(shasum -a 256 event.json | awk '{print $1}')
SIZE=$(wc -c < event.json | tr -d ' ')
# 4. Anchor the hash. Idempotency-Key makes safe retries free.
curl -X POST https://app.satsignal.cloud/api/v1/anchors \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: demo-evt_demo_123" \
-d "{
\"folder_slug\":\"$FOLDER\",
\"sha256_hex\":\"$HASH\",
\"file_size\":$SIZE,
\"category\":\"commitment\",
\"label\":\"Demo AcmeCorp event\",
\"filename\":\"event.json\"
}"
The response:
{
"proof_id": "abc123def456...",
"txid": "5e9a...c4f1",
"mode": "standard",
"category": "commitment",
"folder_slug": "acme-events-dev",
"proof_url": "https://app.satsignal.cloud/w/.../r/abc123def456",
"bundle_url": "https://app.satsignal.cloud/bundle/abc123def456.mbnt"
}
Then anyone — including someone with no Satsignal account — can verify your proof:
# Download the bundle (requires your key)
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
https://app.satsignal.cloud/bundle/abc123def456.mbnt -o proof.mbnt
# Verify it — no key needed
# Web: drop proof.mbnt + event.json on https://proof.satsignal.cloud/verify
# Offline shell: python scripts/agent_anchor.py verify-handoff (see §11)
Python variant
Same call, plain urllib.request. Stdlib only. Useful if you're embedding in a notebook or a small backend job.
import hashlib, json, os, urllib.request
API = "https://app.satsignal.cloud"
KEY = os.environ["SATSIGNAL_API_KEY"]
FOLDER = "acme-events-dev"
with open("event.json", "rb") as fh:
data = fh.read()
sha = hashlib.sha256(data).hexdigest()
body = json.dumps({
"folder_slug": FOLDER,
"sha256_hex": sha,
"file_size": len(data),
"category": "commitment",
"label": "Demo AcmeCorp event",
"filename": "event.json",
}).encode("utf-8")
req = urllib.request.Request(
f"{API}/api/v1/anchors",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Idempotency-Key": "demo-evt_demo_123",
},
)
with urllib.request.urlopen(req) as resp:
out = json.load(resp)
print(out["proof_id"], out["txid"], out["proof_url"])
Conceptual walkthrough — what each step is doing
- Get an API key. Sign in at
app.satsignal.cloudwith a magic link. Mint a key with theanchors:createscope at/w/<workspace>/keys. - Create a folder. Folders are the namespace each proof files under — one per integration is fine. (Legacy request spellings remain accepted — see the Compatibility map.)
- Anchor a commitment. Hash your payload locally, then
POST /api/v1/anchorswithfolder_slug,sha256_hex,file_size,category. The response carriesproof_id,txid,proof_url. - Download the bundle. The response carries
bundle_url— a full URL onapp.satsignal.cloud. GET it with bearer auth; the.mbntzip carries the canonical doc, manifest, miner's signed ARC acceptance. - Verify without us. Drop the bundle (plus the original file) into
proof.satsignal.cloud/verify(account-free) — it runs every check locally in your browser. For a scriptable check, the §11unzip+sha256sumrecipe resolves the txid against any public BSV node — no Satsignal account or API key needed at verify time. (A standalonesatsignal verifyCLI is planned but not yet shipped.)
Running in a webhook handler / CI job / agent runtime where you can't drive a browser? See §11 — the same verification works from agent_anchor.py verify-handoff, a plain unzip + jq pipeline, or the auth-free /lookup_hash endpoint.
7. Production notes
Idempotency
Send Idempotency-Key: <stable-string> on every retry-prone anchor. The notary records the body hash against that key. A replay with the exact same body returns the cached response; a replay with a different body returns 409 idempotency_key_reuse_body_mismatch. Window: 24 hours. After 24 hours the key recycles and the body hash is no longer cached.
Records-before-broadcast: the idempotency record is written before the wallet is touched, so a burst load can return 504 on later requests in the same burst — but a retry with the same key and body gets the original response, not a duplicate anchor. This reframes burst-load 504s from "regression" to "recoverable latency-tail".
Rate limits
The anchor API's only key-level throttle is the plan quota window — there is no separate per-minute or per-hour burst limiter on POST /api/v1/anchors today. Every 2xx and 429 anchor response carries the quota state as headers:
| header | meaning |
|---|---|
X-RateLimit-Limit | anchors allowed in the plan's quota window |
X-RateLimit-Remaining | remaining in the current window |
X-RateLimit-Reset | UTC epoch seconds when the window resets |
X-RateLimit-Window | month (free/starter/pro/scale) or day (legacy trial/paid) |
Current window ceilings: Free 100/month, Starter 10,000/month, Pro 100,000/month, Scale custom — see pricing.
A 429 quota_exceeded from the anchor API means the window is exhausted. It carries the same X-RateLimit-* headers but no Retry-After — wait for X-RateLimit-Reset (the window is a fixed calendar window, not a sliding bucket) or mail hello@satsignal.cloud for a cap lift.
Separately, the auth-free /lookup_hash endpoint has its own hour-windowed limits (120/hour per IP anonymous; 5,000/hour per workspace with a bearer key) and its 429 does carry Retry-After.
Retries
Retryable error classes:
429— forquota_exceededon the anchor API, retrying beforeX-RateLimit-Resetcannot succeed; alert instead. For hour-windowed endpoints (/lookup_hash), back off perRetry-After.5xxand connection-reset / DNS / TLS errors — exponential backoff (start at 1s, cap at 30s, jitter).504 timeout_no_recordsfrom burst loads — retry with the sameIdempotency-Key; the original anchor either landed and the retry returns itsproof_id, or didn't and the retry tries fresh.
Non-retryable:
400(bad body, unknown field, invalid hash format).401/403(key wrong / out-of-scope).404 folder_not_found(folder doesn't exist).409 idempotency_key_reuse_body_mismatch(key reuse with a different body).
Key rotation
Mint a new key at /w/<workspace>/keys, swap in your store, revoke the old one. Existing proofs (their proof_id, txid, bundle_url) are not invalidated by key rotation — proofs are bearer artifacts not tied to the key that anchored them. Only future anchor calls and bundle downloads need the new key.
Quota visibility
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
https://app.satsignal.cloud/api/v1/usage
Returns the current month's count, plan ceiling, and reset epoch. Wire this into your monitoring; surprise quota exhaustion is the single most common cause of "why did anchors stop landing?".
Failed or stuck anchor recovery
If a POST returned a proof_id but you never saw txid materialize (rare — only on a wallet-internal failure), GET /api/v1/anchors/<proof_id> returns the current state. A stuck anchor will eventually re-broadcast on the next sweep cycle (~10 min). If after 30 minutes the proof still has no txid, file a support ticket with the proof_id.
For the full pre-flight checklist (key rotation, broadcast failure recovery, support flow), see Production checklist.
8. Errors you might see
| code | name | meaning | what to do |
|---|---|---|---|
400 | bad_request | malformed JSON, missing field, wrong type | fix the body |
400 | unknown_field | a field name the API doesn't recognize | drop it; check API reference for allowed fields |
400 | invalid_category | category not in the enum | use one of commitment, policy_snapshot, evidence_bundle, memory_checkpoint, document, output |
400 | invalid_sha256 | sha256_hex is not 64 lowercase hex chars | re-hash; ensure lowercase hex |
401 | unauthorized | missing / wrong bearer | check the Authorization header |
403 | forbidden | key lacks the anchors:create scope, or folder is out-of-scope for a scoped key | mint a key with the right scope |
404 | folder_not_found | folder doesn't exist | create the folder first |
409 | proof_set_requires_force_new | same sha256_hex already anchored without proof_set; new request adds a proof_set | send force_new: true |
409 | idempotency_key_reuse_body_mismatch | the Idempotency-Key was used before with a different body | use a fresh key |
429 | quota_exceeded | monthly anchor count exhausted | email hello@satsignal.cloud for a cap lift, or wait for the monthly reset |
504 | timeout_no_records | burst load: records not written within deadline | retry with the same Idempotency-Key |
9. Legacy field aliases (if your code sends them)
Responses emit the canonical field names only. Requests written against the legacy spellings (pre-2026-05-18 vocabulary) keep working forever — they are folded into the canonical names at the boundary. The full canonical/legacy mapping across endpoints, fields, scopes, CLI flags, and error codes lives in the Compatibility map.
10. Where this fits
- If your data is a stream of events from another platform (Stripe, GitHub, Langfuse, custom apps), see Webhooks — the notary acts as the receiver, no client-side hashing needed.
- If your data is an agent's decisions, see Agent runtime — policy snapshot + per-decision commitments + final manifest, all wired into a Python context manager.
- If your data is a CI/CD artifact (eval report, build manifest, release artifact), see CI/CD — composite GitHub Action + shell adapters for GitLab / Bitbucket / Docker / npm / PyPI.
- If you need to batch up to 10,000 items into one anchor, see Manifest.
- If the existence of the hash itself is sensitive (low-entropy rows, sealed-bid auctions), see Sealed mode.
- If you're unsure which exact bytes to hash for your artifact shape, see What to hash.
- If you later need a validated redacted copy + disclosure
.mbntproduced offline from an already-anchored file (CI / air-gapped, no upload, no re-anchor), see Headless redaction.
11. Verifying programmatically (without a browser)
The drag-and-drop verifier at proof.satsignal.cloud/verify is the easiest path for a human checking a single bundle, but CI runners, agent runtimes, and webhook consumers can't drive a browser. This section is the non-browser verify path.
What "verified" actually means
A .mbnt bundle binds three artifacts together:
- A canonical sha256 over the bundle's
canonical.jsonpayload — the content commitment (your file's hash + metadata, in a stable byte order). - A BSV transaction (
txid) whoseOP_RETURNcarries the 20-byte commitment. - A merkle proof linking the canonical sha256 to that transaction.
You can verify each independently:
| Check | Needs | What it proves |
|---|---|---|
canonical.json sha256 matches | bundle only | The bundle is internally consistent. |
| Canonical contains your file's sha256 | bundle + original file bytes | You hold the same bytes as the anchorer. |
On-chain OP_RETURN payload matches | bundle + a BSV explorer | The commitment was actually broadcast. |
| Transaction is in a confirmed block | bundle + chain reader | The proof is "chain-confirmed". |
If you do not have the original file bytes, you can still verify the bundle is self-consistent and that the OP_RETURN commitment was broadcast. What you cannot do is prove the bundle's canonical.json matches some specific unknown external file — that binding is a property of you-the-holder, not the bundle.
Path A — the hosted verifier (no install)
Drop the .mbnt (plus the original artifact) at proof.satsignal.cloud/verify — account-free; it runs the full bundle-integrity + chain-confirm check locally in your browser. Right for a one-off check when you have the file in front of you.
A standalone
satsignal verifyCLI (with--spv/--min-confirmations) that does this in one shell command is planned but not yet shipped — there is nopip install satsignal-cliverify package today. Until it ships, use the hosted verifier above, theunzip+sha256sumrecipe in Path B, or/lookup_hash(Path C). (Verifying an agent-sessionhandoff.jsonpacket — a bundle of many anchors, not a single.mbnt— is a different job:python scripts/agent_anchor.py verify-handoffchain-confirms the session; see the agents guide.)
CI tip — fresh anchors and the explorer-indexing window. Whichever verify path you script, an anchor only resolves on-chain once a public block explorer has indexed the txid. During the explorer-indexing window right after broadcast (typically 1–5 minutes, occasionally longer — ~10) the on-chain lookup can 404 regardless of the bundle being valid, so have CI wait + retry the verify rather than fail the build. The bundle itself is fully verifiable offline in the meantime (Path B); only the chain-resolution step needs the explorer.
Path B — inspect the .mbnt manually
A .mbnt bundle is a zip with two files (manifest.json and canonical.json). For a Bash + jq integrator:
unzip -q proof.mbnt -d /tmp/bundle-extract/
# 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-extract/canonical.json
sha256sum event.json
# both should print the same 64-hex string
# 2. Pull the on-chain commitment.
jq -r '.txid' /tmp/bundle-extract/manifest.json
# -> bc1a35b4c3e5ba8e62c087dfef2f51927677914716d84b1bc3e9d2201218594b
# 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-extract/manifest.json
If the claimed file hash from step 1 equals the sha256 of your file, and you can look up the txid on any BSV explorer (whatsonchain.com, bitails.io, your own node), you have everything the planned satsignal verify CLI would check minus the convenience of one command.
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 (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 want is "has this exact file ever been anchored on this server?", use the public lookup endpoint — 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"}
misson an anchor you know exists? If the anchor was sealed (mode=sealed),/lookup_hashwill always returnmisswithreason: 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/verifybrowser UI (which takes the document + salt) to confirm a sealed anchor, or see the sealed integration guide for the full verification 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_hashwhether this server anchored it — no account needed. If the existence of an anchor is itself sensitive, anchor in sealed mode: sealed anchors commit to an HMAC and are excluded from this endpoint by design.
The query parameter accepts both ?sha=<hex> (the historical canonical) and ?sha256_hex=<hex> (an alias matching the JSON request-body field name elsewhere). Either alone returns the same response. If both are supplied they must agree — different values return 400 conflicting_alias, the same convention /api/v1/anchors uses for folder_slug vs its legacy alias. See the Compatibility map for the full alias table.
/lookup_hash is rate-limited per IP (120/hour by default) and is intentionally CORS-open — third-party verifier UIs can call from any origin without proxying.
RECEIVED vs CONFIRMED — and what to do when explorers lag
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. Indexers like WhatsOnChain typically pick it up within 1–5 minutes of broadcast, occasionally longer (~10 minutes). During that window:
curl https://api.whatsonchain.com/v1/bsv/main/tx/hash/<txid>may return 404.api.bitails.iooften indexes faster — useful as a fallback.- The bundle itself is fully verifiable offline — you do not need an explorer hit to prove the anchor exists.
CONFIRMED means a block has been mined containing the transaction. On BSV this is typically within ~10 minutes of broadcast under normal load. Confirmation depth is a property of the public chain, not of the bundle — poll any explorer for it, and treat RECEIVED as a pending state rather than a failure. A CI gate that waits for depth ≥ 6:
TXID=$(jq -r '.txid' /tmp/bundle-extract/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
Quick decision table
| Your situation | Use |
|---|---|
| One-off verify, you have the file | the hosted verifier (Path A) |
| CI step, file is in the job workspace | unzip + sha256sum (Path B) |
| Webhook consumer, just confirming "did this hash anchor?" | /lookup_hash (Path C) |
| Agent runtime, no Python at hand | unzip + jq (Path B) |
Auditor with a .mbnt but no original file | Path B — verify internal + on-chain consistency only |
| Worried explorer 404 means the proof failed | Don't be — see § RECEIVED vs CONFIRMED above |
Questions about this specification? Email hello@satsignal.cloud.