Worked example: AcmeCorp anchors every order webhook
AcmeCorp is a fictional e-commerce SaaS. They sell fulfillment software to small online merchants, take Stripe-style payment webhooks from their customers' storefronts, and need every
order.completedwebhook tamper-evidently anchored so a disputed-order investigation months later can verify the exact payload AcmeCorp received. This walkthrough follows their integration from "we should anchor webhooks" through "an auditor verifies a disputed event nine months later." Every value below is synthetic — AcmeCorp is a placeholder name,evt_demo_001/proof_abc123…/txid_5e9a…are fake.
Companion docs: Webhooks path guide · API reference · Production checklist · What to hash
1. The scenario
AcmeCorp is a 15-person SaaS that sells order-fulfillment software to ~800 small online merchants. Their merchants run Stripe-shaped checkout flows; on every completed purchase Stripe POSTs an order.completed webhook to AcmeCorp's ingest endpoint, AcmeCorp records the event in their orders table, and downstream fulfillment kicks in (inventory deduction, label printing, the merchant's dashboard updating). They keep an audit log of every webhook body they receive — a few hundred a day, growing.
The problem they hit: a merchant disputes that a particular order ever happened, claims the email on the order isn't theirs, and asks for evidence. AcmeCorp has the row in their database. But the merchant's lawyer argues the row could have been edited at any point — there's nothing tying the bytes in AcmeCorp's database to the bytes Stripe sent on the original delivery date. AcmeCorp's existing audit log is internal storage with internal mutability; "trust us, we didn't change it" is not a thing a court accepts. Three disputed orders in six weeks turned this from a "nice to have" into a launch-blocking compliance gap.
Anchoring with Satsignal solves the tamper-evidence problem: the sha256 of every webhook body is committed into a BSV OP_RETURN output within seconds of delivery. Months later, AcmeCorp can hand a verifier the raw body + a one-click verification link; the chain shows the hash was committed at block time T. Anchoring does not solve authorship (Stripe's signature does that), does not solve "did this customer actually authorize the order" (a payment processor's job), and does not solve "is the data still in AcmeCorp's database" (their own retention policy). What it does prove: at block time T, AcmeCorp held a webhook body whose sha256 matched. If they later show you bytes whose sha256 matches that on-chain hash, those are the bytes from time T. Edited bytes won't match.
2. The choice they made
Webhook ingest path (not Files, not Agents). The events arrive from third-party storefronts via Stripe; they're already JSON, the signature header proves provenance, and AcmeCorp doesn't want to write source-specific anchoring code in their webhook handler. Provision a webhook URL once, paste it into the source's config, done.
Standard mode (not sealed, not manifest). The events aren't secret — they contain customer email and order metadata but no card numbers (Stripe redacts those before delivery). Tamper-evidence is the value; commitment-only sealed mode would buy nothing here and adds a reveal-on-disclosure step they don't need. Volume is modest (hundreds/day, not thousands/hour) so manifest-batched anchors are also overkill.
One folder per environment. acme-orders-prod for production webhooks, acme-orders-staging for the merchant onboarding sandbox. Separate folders so a stress-test in staging never contaminates prod's anchor history, and so the prod folder's audit queries don't have to filter out staging noise.
Idempotency-Key derived from the Stripe event id. Stripe guarantees evt_xxx is unique per delivery. AcmeCorp's webhook worker can retry transient errors and the notary's idempotency cache catches duplicates — no double-anchor, no double-quota burn.
3. Day 1: setting up the integration
The engineer on call (one weekend afternoon) walked through:
export SATSIGNAL_API_KEY=sk_demo_acmecorp_xxxxxx # synthetic
# 1. Create the folder for prod orders.
curl -X POST https://app.satsignal.cloud/api/v1/folders \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "acme-orders-prod", "name": "Acme orders (prod)"}'
# Response:
# {"folder": {"id": "...", "slug": "acme-orders-prod", "name": "Acme orders (prod)",
# "archived": false, "created_at": "2026-05-26T14:02:00Z"}}
# 2. Provision the webhook ingest endpoint for Stripe.
curl -X POST https://app.satsignal.cloud/api/v1/webhooks \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"folder_slug": "acme-orders-prod", "source_type": "stripe"}'
# Response:
# {"webhook_id": "wh_demo_001",
# "url": "https://app.satsignal.cloud/api/v1/webhooks/wh_demo_001",
# "source_type": "stripe",
# "secret_set": false}
AcmeCorp's storefronts deliver Stripe events to AcmeCorp's own ingest URL (their existing handler at https://api.acme.example/v1/stripe), not directly to Satsignal. The wire they wanted: Stripe → AcmeCorp's handler → AcmeCorp does its order processing → AcmeCorp forwards the raw body to the Satsignal webhook URL with the original Stripe-Signature header preserved. That way the notary can verify Stripe's signature against AcmeCorp's installed signing secret, and the canonical bytes anchored are the exact bytes Stripe sent.
# 3. Install Stripe's signing secret (copied from Stripe Dashboard
# → Developers → Webhooks → AcmeCorp's existing endpoint).
curl -X PATCH https://app.satsignal.cloud/api/v1/webhooks/wh_demo_001 \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"signing_secret": "whsec_demo_xxxxxxxx"}'
# Response:
# {"webhook_id": "wh_demo_001", "secret_set": true}
# 4. Fire a Stripe test event from the dashboard. Watch it land:
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
"https://app.satsignal.cloud/api/v1/anchors?folder_slug=acme-orders-prod&limit=5"
# Response carries one anchor row with proof_id + txid.
End-to-end from PR-merged to first-prod-anchor: about 90 minutes, including waiting for the BSV broadcast to confirm.
4. The wire-up code
What runs in AcmeCorp's backend after each webhook delivery. They already had a Stripe handler; the only addition is the forward-to-Satsignal step + the DB write for the anchor metadata.
The schema they added next to their existing orders table:
CREATE TABLE order_anchors (
order_id TEXT NOT NULL,
event_id TEXT PRIMARY KEY, -- Stripe's evt_xxx, dedup key
event_type TEXT NOT NULL, -- 'order.completed', etc.
proof_id TEXT NOT NULL,
txid TEXT NOT NULL,
bundle_url TEXT NOT NULL, -- where the .mbnt lives
proof_url TEXT NOT NULL, -- public verification page
sha256_hex TEXT NOT NULL, -- of the canonical Stripe body
folder_slug TEXT NOT NULL, -- 'acme-orders-prod'
raw_body BLOB NOT NULL, -- the exact bytes Stripe sent
received_at_utc TIMESTAMP NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id)
);
CREATE INDEX idx_order_anchors_order_id ON order_anchors(order_id);
The crucial column is raw_body. AcmeCorp's first design stored only the anchor metadata; they discovered four weeks later that without the canonical bytes, the on-chain hash was a hash of nothing they could produce. The lesson — store the bytes you hashed — appears in many integrators' first-month learnings and is the single most important field on this table. See §7.
The 8 fields AcmeCorp persists per webhook (everything above except order_id + raw_body + received_at_utc):
| Field | Source | Why |
|---|---|---|
proof_id | response body | stable handle for lookups, dashboard, support tickets |
txid | response body | the BSV transaction carrying the OP_RETURN commitment |
bundle_url | response body | where the portable .mbnt lives — download once + cache locally |
proof_url | response body | public verification URL (no Satsignal account needed) |
sha256_hex | response body | the hash that was committed; lets you re-verify without re-hashing the body every time |
folder_slug | response body | grouping namespace for inventory queries |
event_id | Stripe header Stripe-Event-Id (or body id) | AcmeCorp's idempotency key; their primary key for this table |
event_type | Stripe body type | for filtered audit queries — "show me all order.refunded anchors in Q3" |
5. What the data looks like
A Stripe order.completed event as it arrives at AcmeCorp's handler (synthetic):
{
"id": "evt_demo_001",
"type": "order.completed",
"data": {
"object": {
"order_id": "ord_acme_8472",
"amount": 14999,
"currency": "usd",
"customer_email": "purchaser@example.com",
"items": [
{"sku": "ACME-WIDGET-2", "qty": 1, "unit_price": 14999}
],
"merchant_id": "merchant_demo_42"
}
},
"created": 1748400000
}
AcmeCorp's handler computes the sha256 of the raw bytes (no re-serialization — Stripe's bytes are the canonical bytes), then forwards them with the preserved signature header. The Satsignal anchor response that AcmeCorp persists:
{
"proof_id": "proof_abc123def456…",
"txid": "5e9adc4f1bcafeb0000000000000000000000000000000000000000000000000",
"bundle_url": "https://app.satsignal.cloud/bundle/proof_abc123def456.mbnt",
"proof_url": "https://app.satsignal.cloud/w/acmecorp/r/proof_abc123def456",
"folder_slug": "acme-orders-prod",
"sha256_hex": "9a7b2c4d5e6f7081929394a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e",
"mode": "standard",
"category": "commitment",
"webhook_id": "wh_demo_001"
}
What ends up in AcmeCorp's order_anchors row:
order_id ord_acme_8472
event_id evt_demo_001
event_type order.completed
proof_id proof_abc123def456…
txid 5e9adc4f1bcafeb0…
bundle_url https://app.satsignal.cloud/bundle/proof_abc123def456.mbnt
proof_url https://app.satsignal.cloud/w/acmecorp/r/proof_abc123def456
sha256_hex 9a7b2c4d5e6f7081929394a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e
folder_slug acme-orders-prod
raw_body <512 bytes of the Stripe body>
received_at_utc 2026-05-26T14:35:22Z
6. Nine months later: a disputed order
It's February 2027. A customer (Pat Buyer) emails AcmeCorp's support claiming that the ord_acme_8472 charge on their card last May wasn't them — they want a refund and they want evidence AcmeCorp actually received the order on the date claimed, with the email they actually used (their lawyer's claim is that the order email was forged after the fact).
The support engineer's flow:
- Looks up
order_id = "ord_acme_8472"in theorderstable → joins onorder_anchors→ getsproof_id,bundle_url,sha256_hex, and the originalraw_body. - Downloads the
.mbntbundle frombundle_url. (Bundle download fromapp.satsignal.cloudrequires an authenticated API key for workspace-owned proofs; the verification flow downstream does not require Satsignal authentication.) - Opens
proof.satsignal.cloud/verifyin a browser, drops in the.mbnt+ theraw_bodybytes. - The verifier confirms: (a) BSV chain anchor present at block N, block time
2026-05-26T14:35:31Z; (b) sha256 of the dropped body matches the on-chain commitment; (c) bundle's internal manifest is intact + the canonical doc inside matches the txid. - Support engineer screenshots the verifier's pass state + sends Pat (and Pat's lawyer) a link to the public
proof_url. Anyone with that link can re-run the verification independently — no Satsignal account, no AcmeCorp account.
What the screenshot communicates: the bytes AcmeCorp is presenting (the exact event payload with Pat's email on it) had their sha256 committed to the BSV blockchain at 14:35 UTC on 2026-05-26 — nine months before today. If those bytes had been edited (Pat's email swapped in, the amount changed, the date forged), the sha256 wouldn't match. The chain doesn't lie about block time, and sha256 doesn't collide on minor edits. Combined with Stripe's own signature on the event (which AcmeCorp can also re-verify against Stripe's webhook secret, separately), the chain of custody is closed: Stripe vouched for the bytes, BSV timestamped them.
Total support-engineer time to produce the evidence packet: about six minutes. No engineering escalation needed.
7. Production lessons AcmeCorp learned
The integration shipped fine. The post-launch month surfaced four fixes:
Storing the raw body alongside the proof metadata. AcmeCorp's first design only persisted the 8 anchor fields above — they assumed they could pull the body back from their existing Stripe event log when needed. Four weeks in, they ran their first end-to-end verification dry-run and discovered their Stripe log re-serialized the JSON before storing it (key order changed, two optional fields normalized away). The sha256 didn't match. They added a raw_body BLOB column the same day, backfilled by re-querying Stripe's events API for the affected window, and locked the canonical-bytes-only rule into their handler. The takeaway: the proof is meaningless without the canonical bytes; "we can reconstruct it from another source" is not a thing.
Deterministic Idempotency-Key. Their first retry path on the forward-to-Satsignal step used a fresh UUID per attempt. When their webhook worker retried on a transient 503, they double-anchored a handful of events (and double-billed quota). They rewrote the retry to use Idempotency-Key: <event_id> — deterministic on Stripe's event id, so retries replay the cached anchor instead of creating a new one. Per the Production checklist: "A deterministic suffix on your event id works; a random UUID per retry does NOT — it defeats the cache."
Quota visibility. They hit their Free-tier ceiling in week 6 without warning — anchors started 429ing and their webhook worker piled up retries. Fix: emailed hello@satsignal.cloud for an interim cap lift (paid production volume is design-partner today; self-serve billing isn't enabled yet), then wired GET /api/v1/usage into their monitoring with a 75% warning + 90% page threshold. Surprise quota exhaustion is the single most common cause of "why did anchors stop landing?" per the production checklist.
Sealed mode for one VIP merchant. A high-end fashion retailer on AcmeCorp's platform processed VIP customer events whose metadata (high-value purchases tied to identifiable customers) was sensitive enough that they didn't want the raw body to cross the wire to Satsignal at all. AcmeCorp added a parallel webhook + folder configured for sealed mode: the merchant's webhook handler HMAC-blinds the body locally, sends only the blinded commitment, and reveals the body bilaterally with the merchant if a dispute ever requires it. Mainline merchants kept the standard-mode path. See Sealed path guide for the wire shape.
8. Variations on this pattern
If the source isn't Stripe:
source_type: "github"— for webhook-shaped GitHub events (release published, workflow run completed). Same provisioning flow; secret format is whatever you set in GitHub's "Secret" field.source_type: "langfuse"— for LLM observability events (prompt-version create/update/delete). High-value use case: anchoring every prompt-version change establishes a chain-anchored audit log that the prompt-version row hasn't been edited since the anchor block time.source_type: "none"— your own app emitting outbound events with a signing scheme you control. The notary generates the signing secret; you install it in your client's outbound config and sign each delivery yourself.
If the volume scales past ~thousands of webhooks/day, Manifest mode batches up to 10,000 events into one anchor — pay one chain anchor per minute instead of one per event. Trade-off: per-event verification requires the Merkle path inside the manifest, not a standalone commitment. AcmeCorp didn't need this; for a busier customer they'd cross that bridge.
If the payload needs to stay private, Sealed mode anchors a commitment to the body without the body ever crossing the wire to Satsignal. Reveal happens bilaterally with the disputant. This is the path AcmeCorp's VIP-merchant variant used.
9. The full code: AcmeCorp's webhook handler
A complete handler in Python (~40 lines, stdlib + Stripe SDK). This is the production version after they iterated past the lessons in §7:
import hashlib
import hmac
import json
import os
import urllib.request
import stripe # their existing dependency
STRIPE_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"].encode()
SATSIGNAL_URL = "https://app.satsignal.cloud/api/v1/webhooks/wh_demo_001"
SATSIGNAL_KEY = os.environ["SATSIGNAL_API_KEY"]
def handle_stripe_webhook(raw_body: bytes, headers: dict) -> dict:
# 1. Verify Stripe's signature on the raw body. Stripe SDK does
# canonical-string assembly internally; this raises on bad sig.
event = stripe.Webhook.construct_event(
payload=raw_body,
sig_header=headers["Stripe-Signature"],
secret=STRIPE_SECRET.decode(),
)
# 2. AcmeCorp's existing order-processing logic.
if event["type"] == "order.completed":
upsert_order(event["data"]["object"])
# 3. Forward the raw body to Satsignal for anchoring. Preserve
# the Stripe-Signature header so the notary can re-verify it
# against the installed signing secret.
req = urllib.request.Request(
SATSIGNAL_URL,
data=raw_body,
method="POST",
headers={
"Content-Type": "application/json",
"Stripe-Signature": headers["Stripe-Signature"],
"Idempotency-Key": event["id"], # deterministic, retry-safe
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
anchor = json.loads(resp.read())
# 4. Persist the anchor + the canonical raw body.
db.execute(
"""INSERT OR IGNORE INTO order_anchors (
order_id, event_id, event_type, proof_id, txid,
bundle_url, proof_url, sha256_hex, folder_slug,
raw_body, received_at_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))""",
(event["data"]["object"]["order_id"], event["id"], event["type"],
anchor["proof_id"], anchor["txid"], anchor["bundle_url"],
anchor["proof_url"], anchor["sha256_hex"], anchor["folder_slug"],
raw_body),
)
# 5. Return 200 to Stripe. Stripe will retry on non-2xx — handler
# is idempotent (event_id PK + Idempotency-Key forward).
return {"received": True, "proof_id": anchor["proof_id"]}
A few things to notice: raw_body is the bytes parameter, never re-serialized; the Idempotency-Key is the Stripe event["id"], so retries replay; the INSERT OR IGNORE handles the case where AcmeCorp's own handler retries after a partial commit. None of this is Satsignal-specific reliability engineering — it's the "records-before-broadcast" model the notary enforces server-side (see Production checklist §4) playing nicely with a client that does the matching work.
10. Where this fits
- Path guide companion: Webhooks
- Sibling worked example: ResearchAgent agent runtime
- Reference: Production checklist · Compatibility map · What to hash
Legacy aliases footer
Responses emit the canonical proof / folder field names only; requests written against the legacy spellings keep working forever. Full mapping at Compatibility map.
Questions about this specification? Email hello@satsignal.cloud.