orphograph

API documentation

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:

  1. We never see file contents — only the SHA-256 hex digest you compute on your side.
  2. Every receipt verifies against the public Bitcoin chain without us. If our domain dies, your receipt still works with the open-source verifier.
  3. 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.

TypeHeaderWho uses itRate-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.

Session cookies are for the website only. The cookie-based /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

TierPer-IP anchorsBatch sizeNotes
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

StatusMeaningWhen 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. Includes Retry-After header.

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())
Save the receipt locally. The returned 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 missing hashes array, 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 partialpinned, 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}
  ]
}
FieldTypeMeaning
statusstringpending (just submitted), partial (some calendars pinned to BTC), pinned (Bitcoin block contains the Merkle root that includes your hash).
btc_pinned_atstring ‖ nullUTC timestamp the upgrade worker promoted the receipt to pinned. Null until first calendar lands in a block.
checks[]arrayPer-.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.

Trust-minimized verification belongs offline. For evidence-grade verification, ship the receipt JSON + the five .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_label values
  • 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_label you 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:

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

DateVersionChange
2026-05-140.1Public docs page published. Endpoint surface stable; /api/stats and /api/badge/<id>.svg announced as planned for v0.2.
2026-05-130.1Added sha512_hex quantum-hedge sibling field on /api/anchor and /api/anchor/batch. Both backward-compatible.
2026-05-100.1Batch endpoint /api/anchor/batch shipped (50 items max).
2026-05-070.1Initial 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.

GET /api/receipt/<id>.zip

Auth

None for public receipts; session cookie required for private receipts (subscriber-only).

Response

Returns a ZIP file containing the receipt JSON and all 5 .ots proof files. Use this to keep an offline backup of your receipt that can be re-verified by the open-source verifier without our server.

curl

curl -s -O https://orphograph.com/api/receipt/r_abc123.zip
unzip r_abc123.zip
python3 verify_cli.py receipt.json

GET /api/receipt/<id>/summary

Auth

None.

Response

Returns the receipt JSON with extra human-readable fields: what_this_proves, what_this_does_not_prove (a list of legal disclaimers), and how_to_verify (verifier link). Intended for client UIs that want to show users the precise scope of what a receipt is and isn't.

GET /api/config

Auth

None.

Response

Returns public-safe runtime config: Stripe Payment Link URLs, pricing values, feature toggle states, and feature availability flags. The frontend fetches this on load so the founder can update pricing or launch features by setting environment variables — no code edit needed.

{
  "stripe": { "pack_url": "...", "personal_monthly_url": "...", "personal_annual_url": "...", "creator_monthly_url": "..." },
  "pricing": { "pack_usd": 19, "pack_credits": 10, "personal_monthly_usd": 9, "personal_annual_usd": 60, "creator_monthly_usd": 19 },
  "toggles": { "checkout_disabled": false, "anchoring_disabled": false, "maintenance_mode": false },
  "features": { "btc_payments": false, "creator_tier_live": false, "private_receipts": true, "receipt_vault": true }
}

Receipt vault (subscriber)

Auth

Session cookie + active Personal subscription.

GET /api/me/anchors

List all receipts anchored under your subscription. Filter params:

  • limit — page size (1–200, default 50)
  • before — cursor; pass next_before from the previous page
  • q — hash hex prefix filter (case-insensitive)
  • label — substring match on client_label (case-insensitive)
  • privatetrue or false to filter by privacy flag

GET /api/me/anchors.zip

Bulk-download every receipt in the vault as one ZIP. Each receipt is placed in its own <receipt_id>/ folder containing receipt.json + 5 .ots files. Use this for quarterly archive backups.

curl

curl -s -b cookies.txt "https://orphograph.com/api/me/anchors?q=a3f0&private=true"
curl -s -b cookies.txt -O https://orphograph.com/api/me/anchors.zip

POST /api/me/receipt/<id>/privacy

Auth

Session cookie + active Personal subscription. Owner-only.

Request

{ "private": true }

Response

{ "ok": true, "receipt_id": "r_abc123", "private": true }

Toggles the private flag on an existing receipt you own. When private=true, the receipt becomes 404 for everyone except the session cookie holder. The receipt JSON, badge SVG, and ZIP export are all gated.

Returns 404 (not 403) for non-owners to avoid leaking the existence of private receipts via response codes.