A small, stdlib-friendly HTTP API for anchoring file hashes to Bitcoin and reading back receipts. Built for plugin authors, automation, folder watchers, capture-time daemons, and anything that needs to ask "did this exact bag of bytes exist at this exact UTC moment?" without ever sending the bytes themselves.
Three rules govern every endpoint here:
- We never see file contents — only the SHA-256 hex digest you compute on your side.
- Every receipt verifies against the public Bitcoin chain without us. If our domain dies, your receipt still works with the open-source verifier.
- Anchoring is not idempotent on receipt ID. Submitting the same hash twice produces two distinct receipts. This is by design — see Idempotency.
Authentication
Three distinct credential types reach the public surface. Pick whichever one matches the audience your integration is built for; they are mutually exclusive on a single request.
| Type | Header | Who uses it | Rate-limit posture |
|---|---|---|---|
| Pack token | X-Pack-Token |
One-shot buyers ($29 / 50 receipts). The token is the claim code emailed after purchase. | No IP rate limit. Each anchor consumes one credit until the balance is exhausted. |
| API key | X-Orpho-Api-Key |
Personal / Creator subscribers calling from a script, plugin, or server. | No IP rate limit while the underlying subscription is active. Soft fair-use ceiling of 1,000 anchors/day. |
| None (free tier) | — | Anonymous browser drops; quick experimentation from the terminal. | 3 anchors per 24 hours per /24-truncated IP. Returns 429 with a Retry-After header on exhaustion. |
Generate or rotate an API key from your account dashboard.
Keys look like orpho_ + 32 URL-safe characters. We store only the SHA-256
hash of the key — once shown, it cannot be retrieved. Lost a key? Revoke and re-issue.
/api/me/* account endpoints are not part of this
public API surface. They require a browser session and are not designed for
programmatic use. Use X-Orpho-Api-Key instead.
Rate limits
| Tier | Per-IP anchors | Batch size | Notes |
|---|---|---|---|
| Free | 3 / 24h / truncated IP | — | Token bucket; refills continuously at ~3/86400 tokens/sec. Hit it and you get 429. |
| Pack of Fifty ($29 one-shot) | No IP limit | — | One credit consumed per receipt. Pack credits never expire. |
| Standing Order ($9/mo) / Creator ($19/mo) | No IP limit, priority queue | Up to 50 hashes per /api/anchor/batch call |
Soft fair-use ceiling: 1,000 anchors / API key / day. |
When the free-tier limit triggers, the response body includes
retry_after_seconds and limit_per_hour so a polite
client can back off without parsing headers.
Error codes
| Status | Meaning | When you'll see it |
|---|---|---|
400 |
Bad request | Malformed JSON; hash is not 64 lowercase hex characters; receipt ID outside the allowed character set; batch exceeds 50 items; body larger than 4 KB (or 64 KB for batch). |
401 |
Not authenticated | Used by the /api/me/* surface (not public). API key + Pack token failures fall back to free-tier rate limiting and return 429, not 401. |
402 |
Subscription required | You tried to mint an API key without an active subscription. |
404 |
Not found | Receipt ID is well-formed but no receipt exists. Also returned by founder-gated endpoints to anyone without the founder token — they look like they don't exist. |
429 |
Rate limit exceeded | Free tier exceeded 10 anchors/hour. Response body includes retry_after_seconds. Buying a Pack or subscribing removes the limit entirely. |
500 |
Server error | Genuine internal failure. Retry with exponential backoff; if it persists, check /status.html. |
503 |
Dependency unavailable | BTC oracle down (checkout endpoints only) or all five OTS calendars unreachable. Transient — retry in ~60 s. |
POST /api/anchor #
Anchor one SHA-256 hash to five independent OpenTimestamps calendars. The calendars batch many hashes into a single Bitcoin transaction (~hourly), so each receipt's marginal on-chain cost is effectively zero and you are not paying network fees.
Auth
Optional X-Orpho-Api-Key or X-Pack-Token. Anonymous calls fall back to the free-tier limit of 3 anchors per 24 hours.
Request
POST /api/anchor HTTP/1.1
Host: orphograph.com
Content-Type: application/json
X-Orpho-Api-Key: orpho_xxxxxxxxxxxxxxxxxxxxxxxx
{
"hash_hex": "abc123…64-char lowercase hex SHA-256",
"sha512_hex": "def456…128-char lowercase hex SHA-512 (optional, recommended)",
"client_label": "filename or short note (optional, max 200 chars)",
"notify_email": "[email protected] (Pack only — emails the receipt)"
}
Body limit: 4 KB. hash_hex is the only required field. sha512_hex is a quantum hedge — Grover's algorithm halves preimage resistance, leaving SHA-256 at ~2128 and SHA-512 at ~2256. Recording both makes the file→receipt binding harder to forge with a future quantum computer. client_label is opt-in; leave it out if you want the receipt to contain only the hash.
Response (200 OK)
{
"receipt_id": "XwTULwlh76PcCst9",
"created_at": "2026-05-14T14:00:00+00:00",
"hash_hex": "abc123…",
"sha512_hex": "def456…",
"client_label": "photo.jpg",
"calendars_ok": 5,
"calendars_total": 5,
"low_redundancy": false,
"pack_consumed": false,
"pack_remaining": 0,
"subscription_active": true,
"successes": [
{"calendar": "https://a.pool.opentimestamps.org", "ots_path": "receipts/XwTULwlh76PcCst9/a.ots"},
{"calendar": "https://b.pool.opentimestamps.org", "ots_path": "receipts/XwTULwlh76PcCst9/b.ots"},
{"calendar": "https://alice.btc.calendar.opentimestamps.org", "ots_path": "receipts/XwTULwlh76PcCst9/alice.ots"},
{"calendar": "https://finney.calendar.eternitywall.com", "ots_path": "receipts/XwTULwlh76PcCst9/finney.ots"},
{"calendar": "https://btc.calendar.catallaxy.com", "ots_path": "receipts/XwTULwlh76PcCst9/btc.ots"}
],
"failures": []
}
low_redundancy is true when fewer than 3 calendars
accepted the submission — usually transient. Retry in a minute; the
failed calendars will likely succeed.
Errors
400— hash is not 64 lowercase hex chars, or body is malformed JSON.429— free-tier limit exhausted. IncludesRetry-Afterheader.
curl
HASH=$(sha256sum photo.jpg | awk '{print $1}')
HASH512=$(sha512sum photo.jpg | awk '{print $1}')
curl -sS -X POST https://orphograph.com/api/anchor \
-H "Content-Type: application/json" \
-H "X-Orpho-Api-Key: $ORPHO_API_KEY" \
-d "{\"hash_hex\":\"$HASH\",\"sha512_hex\":\"$HASH512\",\"client_label\":\"photo.jpg\"}"
Python (stdlib)
import hashlib, json, os, urllib.request
def anchor(path):
s256, s512 = hashlib.sha256(), hashlib.sha512()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
s256.update(chunk); s512.update(chunk)
body = json.dumps({"hash_hex": s256.hexdigest(),
"sha512_hex": s512.hexdigest(),
"client_label": os.path.basename(path)}).encode()
req = urllib.request.Request("https://orphograph.com/api/anchor",
data=body, method="POST",
headers={"Content-Type": "application/json",
"X-Orpho-Api-Key": os.environ["ORPHO_API_KEY"]})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
receipt_id is what you'll feed to /api/verify/<id>
later. Don't rely on us to remember it for you — write the full JSON
response next to the original file. Anonymous free-tier receipts may
be pruned after the retention window.
POST /api/anchor/batch #
Anchor up to 50 hashes in a single round trip. Useful for folder watchers replaying a backlog, capture-daemon flushes, or batch migrations. Each item produces its own independent receipt and can succeed or fail independently.
Auth
Same model as /api/anchor. Pack tokens consume one credit per item; the rate limit applies once to the whole batch on the free tier.
Request
POST /api/anchor/batch
Content-Type: application/json
X-Orpho-Api-Key: orpho_xxxxxxxxxxxxxxxxxxxxxxxx
{
"hashes": [
{"hash_hex": "abc1…", "sha512_hex": "def1…", "client_label": "img_001.jpg"},
{"hash_hex": "abc2…", "sha512_hex": "def2…", "client_label": "img_002.jpg"}
]
}
Body limit: 64 KB. Maximum 50 items per request.
Response (200 OK)
{
"ok": true,
"submitted": 2,
"succeeded": 2,
"failed": 0,
"results": [
{"index": 0, "ok": true, "receipt_id": "abc...", "created_at": "...", "calendars_ok": 5, "calendars_total": 5, "low_redundancy": false, "client_label": "img_001.jpg"},
{"index": 1, "ok": true, "receipt_id": "def...", "created_at": "...", "calendars_ok": 5, "calendars_total": 5, "low_redundancy": false, "client_label": "img_002.jpg"}
]
}
Errors
400— body missinghashesarray, or array empty, or more than 50 items.429— free-tier limit exhausted.
GET /api/receipt/<receipt_id> #
Fetch the canonical receipt JSON for a previously anchored hash.
This is the same payload you originally received from /api/anchor,
plus any upgrade events the background worker has added since
(status transitions to partial → pinned, BTC pin
timestamp once the calendar's Merkle root lands in a block).
Auth
None. Receipts are world-readable by ID — the ID itself is the unguessable bearer credential (96 bits of entropy from secrets.token_urlsafe(12)).
Request
GET /api/receipt/XwTULwlh76PcCst9
Response (200 OK)
{
"receipt_id": "XwTULwlh76PcCst9",
"found": true,
"created_at": "2026-05-14T14:00:00+00:00",
"hash_hex": "abc123…",
"sha512_hex": "def456…",
"client_label": "photo.jpg",
"status": "pinned",
"btc_pinned_at": "2026-05-14T15:10:12+00:00",
"calendars_ok": 5,
"calendars_total": 5,
"checks": [
{"file": "a.ots", "magic_ok": true, "hash_match": true, "ok": true},
{"file": "alice.ots", "magic_ok": true, "hash_match": true, "ok": true},
{"file": "b.ots", "magic_ok": true, "hash_match": true, "ok": true},
{"file": "btc.ots", "magic_ok": true, "hash_match": true, "ok": true},
{"file": "finney.ots", "magic_ok": true, "hash_match": true, "ok": true}
]
}
| Field | Type | Meaning |
|---|---|---|
status | string | pending (just submitted), partial (some calendars pinned to BTC), pinned (Bitcoin block contains the Merkle root that includes your hash). |
btc_pinned_at | string ‖ null | UTC timestamp the upgrade worker promoted the receipt to pinned. Null until first calendar lands in a block. |
checks[] | array | Per-.ots validation: magic bytes intact and the embedded hash matches what the receipt claims. |
Errors
400— receipt ID does not match^[A-Za-z0-9_-]{1,64}$.404— no receipt exists with that ID, or it has been pruned (free-tier retention).
curl
curl -sS https://orphograph.com/api/receipt/XwTULwlh76PcCst9
Python (stdlib)
import json, urllib.request
with urllib.request.urlopen(
"https://orphograph.com/api/receipt/XwTULwlh76PcCst9", timeout=15
) as r:
receipt = json.loads(r.read())
print(receipt["status"], receipt.get("btc_pinned_at"))
GET /api/verify/<receipt_id> #
Re-verify a receipt server-side: parse each .ots file, confirm
its OpenTimestamps magic bytes are intact and the embedded SHA-256
matches the receipt's recorded hash. Returns the same shape as
/api/receipt/<id> but recomputes calendars_ok from a
live read of every proof file on disk — useful for "is this receipt
still healthy?" health checks.
.ots files through the open-source
verify_cli.py
against your local copy of the file. This endpoint is a convenience
for live integrations — it asks us; the CLI asks Bitcoin.
Auth
None.
Request
GET /api/verify/XwTULwlh76PcCst9
Response
Identical shape to /api/receipt/<id>. The checks[] array reflects a fresh re-read of the proof files; if any .ots file is missing or corrupted, the corresponding entry will have ok: false with the failed sub-check.
Errors
400— receipt ID does not match the allowed pattern.404— receipt not found.
curl
curl -sS https://orphograph.com/api/verify/XwTULwlh76PcCst9 | python3 -m json.tool
GET /api/health #
Public liveness snapshot. Counts only — no email addresses, no claim codes, no IP addresses, no per-customer detail. Cached server-side for ~30 seconds so polling cannot degrade performance. Safe to wire into your own status board.
Auth
None.
Request
GET /api/health
Response (200 OK)
{
"ok": true,
"version": "0.1.0",
"boot_at": "2026-05-14T00:00:00+00:00",
"uptime_sec": 50400,
"counts": {
"receipts_on_disk": 1234
},
"ledger_bytes": {
"anchor_ledger": 245678,
"upgrade_log": 12345,
"expiry_log": 6789
},
"last": {
"anchor_at": "2026-05-14T14:00:00+00:00",
"upgrade_run_at": "2026-05-14T13:55:00+00:00",
"expiry_run_at": "2026-05-14T12:00:00+00:00"
},
"calendars": [
{"url": "https://a.pool.opentimestamps.org", "reachable": true},
{"url": "https://b.pool.opentimestamps.org", "reachable": true},
{"url": "https://alice.btc.calendar.opentimestamps.org", "reachable": true},
{"url": "https://finney.calendar.eternitywall.com", "reachable": true},
{"url": "https://btc.calendar.catallaxy.com", "reachable": true}
],
"btc_oracle": {"available": true, "usd_per_btc": 67500.0, "source": "coingecko"},
"payout": {"configured": true, "address_pool_size": 100, "xpub_set": true},
"checked_at": "2026-05-14T14:00:30+00:00"
}
The data here is intentionally non-sensitive: counts and timestamps,
not contents. The payout block reports only whether BTC
checkout is configured and the size of the unused-address pool —
actual balances are not exposed on this endpoint.
curl
curl -sS https://orphograph.com/api/health | python3 -m json.tool
Python (stdlib)
import json, urllib.request
with urllib.request.urlopen("https://orphograph.com/api/health", timeout=10) as r:
health = json.loads(r.read())
print(f"{health['counts']['receipts_on_disk']} receipts, uptime {health['uptime_sec']}s")
GET /api/btc-order/<order_id>/qr.svg #
Server-rendered SVG QR code encoding a BIP-21 payment URI for a previously created Pack purchase order. The QR contains the public BTC address and amount only — no email, no order ID, no customer-identifying data is encoded into the image, so the QR cannot be used downstream to dox the purchaser.
Auth
None, but the order ID is unguessable. Anyone with the ID can read the public payment fields — exactly what a wallet app needs.
Request
GET /api/btc-order/btc_AbCdEf12345_xyz/qr.svg Accept: image/svg+xml
Order IDs match ^btc_[A-Za-z0-9_-]{1,32}$. Mismatched IDs return 400; well-formed IDs that don't exist return 404.
Response
200 OK, Content-Type: image/svg+xml; charset=utf-8.
The SVG renders a square QR encoding a string of the form:
bitcoin:bc1q…?amount=0.00010473
The companion JSON endpoint GET /api/btc-order/<order_id> (no /qr.svg suffix) returns the order status:
{
"order_id": "btc_AbCdEf12345_xyz",
"status": "awaiting_payment",
"address": "bc1q…",
"amount_sats": 10473,
"expires_at": "2026-05-14T15:00:00+00:00",
"tx_hash": null
}
Errors
400— order ID does not match the allowed pattern.404— order not found (typo or expired).500— QR encoder rejected the BIP-21 URI (rare; misconfigured address).
Embedding
The SVG is referenced directly from an <img> tag:
<img src="https://orphograph.com/api/btc-order/btc_AbCdEf12345_xyz/qr.svg"
width="240" height="240" alt="Bitcoin payment QR">
GET /api/stats #
Public marketing-metrics snapshot. Strictly aggregate counts and the names of public OTS infrastructure — never customer emails, filenames, IPs, BTC balances, or any per-customer detail. Cached server-side for ~60 seconds. The response is byte-identical for every viewer, so it is safe to embed and aggressively cache.
Auth
None.
Request
GET /api/stats
Response (200 OK)
{
"version": "0.1.0",
"uptime_sec": 50400,
"boot_at": "2026-05-14T00:00:00+00:00",
"anchors": {
"total": 12345,
"last_24h": 142,
"last_7d": 980,
"last_anchor_at": "2026-05-14T14:00:00+00:00"
},
"calendars": {
"reachable": 5,
"total": 5,
"items": [
{"name": "a", "reachable": true},
{"name": "b", "reachable": true},
{"name": "alice", "reachable": true},
{"name": "finney", "reachable": true},
{"name": "btc", "reachable": true}
]
},
"btc_oracle": {
"available": true,
"source": "coingecko",
"usd_per_btc": 67500.0
},
"checked_at": "2026-05-14T14:00:30+00:00"
}
Privacy invariants
The endpoint's contract guarantees these fields are never emitted:
- Customer email addresses
- Filenames or
client_labelvalues - IP addresses (raw or truncated)
- Per-customer counts of any kind
- BTC balances (hot or cold)
- Individual receipt IDs
curl
curl -sS https://orphograph.com/api/stats | python3 -m json.tool
GET /api/badge/<receipt_id>.svg planned v0.2 #
Planned server-rendered "Anchored to Bitcoin · view receipt" SVG badge, suitable for static embeds in README files, photo portfolios, and galleries where you need an image rather than a script tag.
Until v0.2, use the existing JavaScript badge — one line of HTML pastes a live badge that links to the print-friendly receipt page, with no external fetches and no tracking:
<script src="https://orphograph.com/badge.js"
data-receipt="XwTULwlh76PcCst9"
async></script>
See the badge demo for the live rendered output and tampering notes.
Idempotency
Anchoring is intentionally not idempotent on the
receipt ID. Submitting the same hash to /api/anchor twice
produces two distinct receipts, each with its own
receipt_id and its own created_at timestamp.
This is by design:
- A receipt asserts existence at a specific UTC moment. Two anchors of the same file at different times are legitimately different claims — e.g., "I had this file on Monday" and "I still had this file on Friday."
- Pack and subscription billing is per-receipt. Folding a duplicate into a single receipt would silently change the cost model.
- The OpenTimestamps calendars themselves are not idempotent on a 32-byte digest at the protocol layer.
If you want at-most-once semantics inside your own integration,
deduplicate before calling /api/anchor (e.g., keep a
local hash_hex → receipt_id table). The server will not
do this for you.
Privacy promise
We never see file bytes. The only thing that reaches our server is a
64-character hex digest you compute on your side. The browser flow
uses crypto.subtle.digest for the same reason. Receipts
contain:
- The SHA-256 hex you sent (mandatory).
- The SHA-512 hex you sent, if you sent one (optional).
- The free-text
client_labelyou sent, if any. This is the only field where your content choice can leak metadata — if you put a filename, the filename gets stored. If you put nothing, nothing gets stored. - A creation timestamp (UTC, second-precision).
- Five OTS proof blobs (binary, ~600 bytes each).
Receipts are world-readable by ID. The ID itself is the bearer
credential — anyone you give it to can read the receipt, but the
receipt does not bind to any specific identity unless you put your
identity into client_label. Pack tokens and API keys are
stored only as SHA-256 hashes; we cannot retrieve them after issuance.
Versioning
Current version: 0.1, surfaced via the version
field on /api/health. Endpoints under /api/*
are the v0.1 surface. Additive changes (new optional fields, new
endpoints, new auth headers) will land here without bumping the major
version. Breaking changes — renamed fields, removed fields, changed
semantics, narrower validation — will land under a /v2/
prefix and leave the v0.1 path operating in parallel for at least
six months from the v2 announcement.
Pre-launch caveat: we have no paying API customers as of publication, so the version-1 surface itself may still shift before being declared stable. Pin to a known-good response shape in your client and treat unknown fields as forward-compatible.
SLA & status
No formal SLA on the v0.1 free tier. Best-effort uptime; honest
reporting at /status.html and structured
liveness at /api/health.
Calendar reachability is reported per-calendar — your client should
treat 3-of-5 calendars succeeding as a healthy anchor (the
low_redundancy flag fires below that threshold).
Paid Personal and Creator tiers are not yet under a written SLA. If you need one for procurement, write to [email protected] and describe your use case.
SDKs & plugins
Stdlib-only Python and curl are deliberately the primary integration surface — no SDK dependency to audit, no version drift, no transitive supply chain. Three first-party integrations live on GitHub:
-
Claude Code plugin —
github.com/Orphograph/Orphograph (
marketplace/orphograph-plugin/). Drop-in plugin for the Anthropic CLI; anchors session artifacts and transcripts from inside a coding session. -
Lightroom plugin —
github.com/orphograph/orphograph (
lightroom-plugin/). Anchors RAW and exported JPGs at export time from inside Adobe Lightroom Classic. SHA-256 is computed on the export filename's bytes locally; only the hash reaches the API. -
Capture daemon —
github.com/orphograph/orphograph (
capture/). Headless watcher that anchors files as they appear in a folder — DSLR tethering target, screenshot folder, recording dump. Targets the Creator tier's capture-time provenance use case.
The standalone open-source verifier — useful when you need to verify
a receipt without calling us, or after we are gone — lives at
github.com/orphograph/orphograph
under server/verify_cli.py. Zero dependencies, stdlib only.
Changelog
| Date | Version | Change |
|---|---|---|
| 2026-05-14 | 0.1 | Public docs page published. Endpoint surface stable; /api/stats and /api/badge/<id>.svg announced as planned for v0.2. |
| 2026-05-13 | 0.1 | Added sha512_hex quantum-hedge sibling field on /api/anchor and /api/anchor/batch. Both backward-compatible. |
| 2026-05-10 | 0.1 | Batch endpoint /api/anchor/batch shipped (50 items max). |
| 2026-05-07 | 0.1 | Initial Pack token + API key auth model. |
Contact
Bug reports, schema concerns, or "I need this thing your docs don't mention" — write to [email protected]. Plain text is fine. Receipts of past bug reports are anchored on request.