Webhooks — anchor events from any SaaS that speaks outgoing webhooks
Most SaaS products already emit signed webhooks for the things you care about (payments, deploys, prompt-version updates, billing events, CI runs). Provision a Satsignal webhook URL, paste it into your source's config, and every delivery is canonicalized, sha256d, and anchored on chain — no SDK, no per-source integration code, no client-side hashing. This guide covers the source-agnostic API surface plus per-source recipes for Stripe, GitHub, Langfuse, and the bring-your-own-signer fallback.
Companion docs: API reference · OpenAPI spec · What to hash · Production checklist · Compatibility map · Worked example: AcmeCorp
1. The 60-second framing
You provision an endpoint at POST /api/v1/webhooks. That returns a Satsignal-hosted URL like https://app.satsignal.cloud/api/v1/webhooks/wh_.... You paste that URL into your source's webhook config — Stripe's webhook settings, GitHub's repo settings, Langfuse's project settings, or your own app's delivery target. From then on, every signed event that lands at that URL is signature-verified, body-hashed, and anchored into the folder you picked when you provisioned the endpoint.
You don't hash anything client-side. You don't run an SDK. The "webhook body" is the canonical artifact — the raw bytes that arrived, in source-byte order, with the signature header that proves they came from the source. The notary records the body hash, anchors it into the webhook's bound folder, and returns the proof. List a webhook's proofs later via GET /api/v1/anchors?folder_slug=<the-webhook-folder>.
2. Use this when
- The events you care about already flow out of an existing SaaS as signed webhooks. You don't want to write source-specific integration code.
- You need an immutable, BSV-anchored audit log of "this exact event payload arrived on this date" for compliance, billing reconciliation, or forensic replay.
- You're integrating against a platform that speaks one of the ship-today source types (
stripe,github,langfuse) and want signature verification handled for you. - You're emitting events from your own app and want the bring-your-own-signer path (
source_type: "none") — Satsignal generates a signing secret, your client signs, the notary verifies and anchors. - You want a single anchor target for fan-out from multiple sources into the same folder.
Don't use this when:
- You only have files on disk — use Files instead.
- You want client-side hashing (the body never leaves your machine) — the zero-code webhook path intentionally hashes server-side, so the raw body crosses the wire. That trade is usually fine when you anchor your own non-sensitive events. It is not fine when you're embedding anchoring for your tenants and your headline is "your data never leaves your machine." For that, don't abandon the product — use the edge-hashing recipe in §5, which keeps the body on your side and sends only digests.
- You're batching > 100 events at a time — use Manifest for Merkle-batched anchors.
3. What you send
Step 1 — provision the endpoint (one-time per folder + source)
POST /api/v1/webhooks
Authorization: Bearer sk_...
Content-Type: application/json
| field | type | required | meaning |
|---|---|---|---|
folder_slug | string | yes | folder the resulting proofs file under. |
source_type | string | yes | one of stripe, github, langfuse, none. Selects the signature scheme. |
signing_secret | string | no | optional; usually set later via PATCH after the source generates one. For source_type: "none" the notary generates this for you. |
Response:
{
"webhook_id": "wh_abc123",
"url": "https://app.satsignal.cloud/api/v1/webhooks/wh_abc123",
"source_type": "stripe",
"secret_set": false
}
The url is what you paste into the source's webhook config. secret_set: false means the notary will accept deliveries but return 401 on each — you need to install the signing secret first (step 2).
Step 2 — install the signing secret
For sources that generate the secret (Stripe, GitHub, Langfuse): copy the secret from the source's UI, then:
PATCH /api/v1/webhooks/wh_abc123
Authorization: Bearer sk_...
Content-Type: application/json
{"signing_secret": "whsec_..."}
For source_type: "none": the notary already generated the secret on provisioning. Read it via GET /api/v1/webhooks/wh_abc123 and install it in your client's outbound config.
Step 3 — events flow
The source delivers events to https://app.satsignal.cloud/api/v1/webhooks/wh_abc123. For each delivery:
- The notary verifies the signature header per the source's scheme (see §Recipes below). Bad signature or stale timestamp →
401, no anchor created, no quota burn. - On verification: the raw body is sha256d as the canonical bytes.
- An anchor is created in the webhook's bound folder, with
category: "output"and alabelofwebhook:<source_type>(unless you set a customlabelon the webhook config). - The response carries the standard anchor response (
proof_id,txid,proof_url).
Retries dedup on body sha: if the same source re-delivers the same body (which happens — Stripe re-tries up to 3 days), the notary returns the original anchor's proof_id without burning a fresh quota slot.
4. What you store
The webhook config itself is durable server-side; you don't have to persist it. What you DO persist:
webhook_id— your stable handle to the endpoint, used to PATCH/revoke it via/api/v1/webhooks/<webhook_id>. (Note: the anchors-list endpoint does not filter bywebhook_id; query a webhook's proofs by its boundfolder_sluginstead.)- The signing secret — only if you're using
source_type: "none". For Stripe / GitHub / Langfuse the source is the source of truth for the secret; the notary stores it encrypted for verification but doesn't expose it back. - For each event you care about: the
proof_idandtxidfrom the per-delivery anchor response. You can also query them after the fact viaGET /api/v1/anchors?folder_slug=<the-webhook-folder>. - The raw bodies of events you care about. This is the most-missed step. The notary anchors a hash of the raw body — but for a verifier to later confirm that this specific event matches the on-chain commitment, they need the body bytes back. Source-side log retention is usually sufficient; if not, pull the body from
proof.satsignal.cloud/r/<proof_id>(which keeps a copy as part of the bundle) or persist on your side.
The exact-bytes property cuts both ways here: the body is the canonical artifact, but the body is also potentially sensitive (card holder names, PII). Webhook deliveries that should not be anchored as plaintext should not flow through this path — either filter them at the source, or use the Files path with client-side canonicalization to a redacted form. For platform-scale ingest — where you're anchoring tenant events and the body must never reach Satsignal at all — see §5: Platform-scale ingest without the raw body leaving your edge.
5. Platform-scale ingest without the raw body leaving your edge
The zero-code webhook path is server-side hashing by design: the notary verifies the source's signature (Stripe / GitHub / Langfuse HMAC over the raw bytes) and only then anchors, so it must receive the raw body. That is the deliberate trade — zero integration code in exchange for the body crossing the wire — and it is the right call when you anchor your own, non-sensitive events.
It is the wrong call when you are a platform reselling anchoring to your tenants and your headline is "your data never leaves your machine." Routing tenant payloads — PII, card holder names, credential-adjacent tool arguments — through a third party to be hashed inverts the exact guarantee you would be selling. You don't have to give up the privacy story to anchor at platform scale: hash at your own edge and send Satsignal only the digests.
The building block already exists — the Manifest path takes a list of {label, sha256_hex} items and binds up to 10,000 of them under one on-chain anchor. You compute the hashes; only the hashes cross the wire. The recipe:
- Receive the SaaS webhook at your own edge — the receiver, worker, or function you already run in front of your tenants (Cloudflare Worker, Lambda, your ingress service). Verify the source signature there. You already verify it, so no trust is lost by moving the check to your side.
- Canonicalize and hash the body locally. Plain SHA-256 over the raw bytes (Rule A in What to hash) is the default. If your event bodies are low-entropy — a small, guessable set of fields — a plain hash is brute-forceable, so use a sealed (HMAC) leaf instead; see Sealed mode.
- Batch the digests and anchor via the manifest path:
POST /api/v1/anchorswithitems: [{label, sha256_hex}, …], up to 10,000 per anchor. Only the{label, sha256_hex}digests leave your infrastructure — never the body. - Persist the raw bodies in your own store (same as §4 below). A verifier later re-hashes a body and confirms its Merkle inclusion path against the on-chain root — without Satsignal ever having held the body.
# Runs in YOUR edge/ingest service — the body never leaves it.
import hashlib, json, os, urllib.request
SATSIGNAL_API_KEY = os.environ["SATSIGNAL_API_KEY"]
def edge_hash(raw_body: bytes) -> str:
# Rule A: hash the exact source bytes. (For low-entropy bodies,
# swap in an HMAC leaf and anchor via Sealed mode instead.)
return hashlib.sha256(raw_body).hexdigest()
# Accumulate one batch per flush window (size it under 10,000).
items = []
def on_delivery(raw_body: bytes, source_signature_ok: bool, event_id: str):
if not source_signature_ok: # you verify the source HMAC here
return # — same check the notary would do
items.append({"label": event_id, "sha256_hex": edge_hash(raw_body)})
# ... your own durable store keeps raw_body for later verification ...
def flush(folder_slug: str):
if not items:
return
req = urllib.request.Request(
"https://app.satsignal.cloud/api/v1/anchors",
data=json.dumps({"folder_slug": folder_slug, "items": items,
"category": "evidence_bundle"}).encode(),
method="POST",
headers={"Authorization": f"Bearer {SATSIGNAL_API_KEY}",
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
print(resp.status, resp.read()) # one proof_id + txid for the batch
items.clear()
What you keep: the "data never leaves your machine" guarantee holds at platform scale, plus the up-to-10,000-items-per-anchor economics (one billed anchor per batch, not per event).
What you take on, versus the zero-code path: you run the signature verification and hashing yourself (a small edge function), instead of pasting a URL into a source's config — and you own the per-source dedup the notary otherwise does on raw-body SHA. Choose the zero-code webhook path when the body is non-sensitive and you want no code; choose edge hashing when you are embedding for tenants and the body must stay on your side.
A packaged edge-hasher SDK (drop-in JCS canonicalization + manifest batching for popular edge runtimes) is on the roadmap; the recipe above runs today on stdlib alone.
6. What verification needs later
To verify a webhook-anchored event later, a verifier needs:
- The
.mbntbundle — fetch frombundle_urlon the anchor response. - The raw webhook body — exactly the bytes that arrived at the notary. Source-byte order, source-byte content, no re-serialization. JSON.parse → JSON.stringify breaks the hash.
- A BSV node — any public one; no Satsignal account required.
The verifier re-hashes the raw body, confirms it matches the canonical doc's byte_exact.hash, and confirms the bundle binds to the txid carried in the manifest.
Cross-link: What to hash — Webhook bodies goes deeper on the raw-body-vs-canonical-envelope trade-off and when to use which.
7. Copy-paste example
export SATSIGNAL_API_KEY=sk_...
# 1. Provision an endpoint for Stripe events into the
# "stripe-events" folder.
curl -X POST https://app.satsignal.cloud/api/v1/webhooks \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"folder_slug": "stripe-events", "source_type": "stripe"}'
# Response:
# {"webhook_id": "wh_...",
# "url": "https://app.satsignal.cloud/api/v1/webhooks/wh_...",
# "source_type": "stripe", "secret_set": false}
# 2. Paste the `url` into Stripe Dashboard → Developers → Webhooks.
# Stripe shows you a `whsec_...` signing secret once. Copy it.
# 3. Install the secret.
curl -X PATCH https://app.satsignal.cloud/api/v1/webhooks/wh_abc \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"signing_secret": "whsec_..."}'
# 4. Trigger a test event from Stripe ("Send test webhook"). Watch
# it land in the webhook's bound folder:
curl -H "Authorization: Bearer $SATSIGNAL_API_KEY" \
"https://app.satsignal.cloud/api/v1/anchors?folder_slug=stripe-events&limit=5"
8. Production notes
Per-webhook rate limit
Each wh_id is capped at 60 deliveries / minute. Above that the notary returns 429 + Retry-After. This defends a leaked signing secret from being used to enumerate-anchor your account without throttling legitimate Stripe / GitHub / Langfuse fan-out during a busy event spike.
The cap is per-wh_id, not per-folder. A folder receiving from two webhook endpoints can absorb 120/minute aggregate.
Idempotency
Webhook deliveries dedup on raw-body sha256. A source re-delivering the same body (Stripe's automatic retry, GitHub's redelivery button) returns the original anchor's proof_id without a fresh anchor. This is automatic — you don't supply an Idempotency-Key header on the source side, the notary derives the dedup key from the body itself.
If the source re-delivers with a body that should be considered distinct (a status update on the same event id), the bodies differ and a fresh anchor is created. This is the source's call, not the notary's.
Retries on the source side
| event | behavior |
|---|---|
| signature verification fails | 401 to source; source retries per its own policy. No quota burn. |
| body parses, anchor in flight | 200 returned only after the anchor lands. |
| anchor pipeline 5xx | 503 to source; source retries. Notary tracks delivery attempts; persistent failures surface on the webhook config page. |
| body dedup (same sha as a prior delivery) | 200 with cached proof_id. No quota burn. |
Signing-secret rotation
For sources that own the secret (Stripe, GitHub, Langfuse): rotate in the source's UI, copy the new secret, PATCH it onto the webhook config. There's a small window where the old secret won't verify; either pause deliveries during rotation or accept the few 401s.
For source_type: "none": rotate via POST /api/v1/webhooks/wh_.../rotate-secret. The response carries the new secret; install it in your client. The old secret stops verifying immediately.
Quota visibility
Webhook-anchored events count against the same monthly anchor quota as direct-API anchors. GET /api/v1/usage returns the aggregate. Webhook fan-out can exhaust quota fast on a busy source — wire quota alerts before enabling a high-volume webhook.
For the full pre-flight checklist (key rotation, broadcast failure recovery, support flow), see Production checklist.
9. Errors you might see
| code | name | meaning | what to do |
|---|---|---|---|
400 | unsupported_source_type | source_type not in the enum | use stripe, github, langfuse, or none |
400 | signing_secret_required | PATCHing a secret of the wrong format for the source type | check the source's secret format (whsec_... for Stripe, etc.) |
401 | bad_signature | signature header missing or doesn't verify | check the secret is installed; for Stripe / GitHub re-copy from the source UI |
401 | stale_timestamp | signature header's timestamp is > 5 minutes from now | source clock skew; usually self-corrects |
404 | webhook_not_found | wh_id doesn't exist or was deleted | re-provision |
429 | rate_limited | per-wh_id 60/min cap | honor Retry-After; source should back off |
503 | anchor_pipeline_failed | upstream wallet/broadcast failure | source will retry; no action needed unless persistent |
10. Legacy field aliases (if your code sends them)
Webhook-anchored proofs carry the same response shape as direct-API anchors: responses emit the canonical proof / folder names only, and requests written against the legacy spellings keep working forever.
Full canonical/legacy mapping across endpoints, fields, scopes, CLI flags, and error codes: Compatibility map.
11. Recipes
Worked example: AcmeCorp anchors every order webhook — full integration walkthrough from "we should anchor webhooks" through a 9-month-later disputed-order audit.
Per-source notes. The protocol-level source type names below (stripe, github, langfuse) are wire-protocol enum values — they ARE documented by name on the API surface because that's fact about which signature scheme runs.
Stripe (source_type: "stripe")
- Signature header.
Stripe-Signature. Format:t=<unix>,v1=<hmac>(and possibly more comma-separated pairs). - Canonicalization rule. The notary verifies
v1 == HMAC-SHA256(signing_secret, "<t>.<raw_body>")per Stripe's documented scheme. The raw body is the canonical bytes anchored. - Source UI. Dashboard → Developers → Webhooks → Add endpoint. Paste the Satsignal
url. Subscribe to whatever event types you want anchored (e.g.checkout.session.completed,invoice.payment_succeeded). - Signing secret. Stripe reveals
whsec_...once on creation. Copy it immediately; if you miss the copy window, Stripe lets you reveal it again from the endpoint page.
# Install the secret Stripe gave you
curl -X PATCH https://app.satsignal.cloud/api/v1/webhooks/wh_abc \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"signing_secret": "whsec_..."}'
GitHub (source_type: "github")
- Signature header.
X-Hub-Signature-256. Format:sha256=<hmac>. - Canonicalization rule. The notary verifies
HMAC-SHA256(signing_secret, raw_body)per GitHub's documented scheme. The raw body is the canonical bytes anchored. - Source UI. Repo → Settings → Webhooks → Add webhook. Paste the Satsignal
url. Content type:application/json. Pick the events you want (e.g.push,release,workflow_run). - Signing secret. You pick it — paste the same string into GitHub's "Secret" field AND PATCH it onto the Satsignal webhook. GitHub doesn't reveal it back later, so keep your copy.
SECRET=$(openssl rand -hex 32)
echo "Use this in GitHub's Secret field: $SECRET"
curl -X PATCH https://app.satsignal.cloud/api/v1/webhooks/wh_abc \
-H "Authorization: Bearer $SATSIGNAL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"signing_secret\": \"$SECRET\"}"
Langfuse (source_type: "langfuse")
- Signature header.
x-langfuse-signature. Format:t=<unix>,v1=<hmac>(Stripe-style). - Canonicalization rule. The notary verifies
v1 == HMAC-SHA256(signing_secret, "<t>.<raw_body>")per Langfuse's documented scheme. The raw body is the canonical bytes anchored. - Source UI. Langfuse project → Settings → Webhooks. Paste the Satsignal
url. Subscribe toprompt-versionevents (create / update / delete). - Signing secret. Langfuse reveals it once on webhook creation. Copy and PATCH onto the Satsignal webhook.
This is the high-value Langfuse 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. Langfuse shows what prompts shipped; the BSV proof establishes when.
Generic / bring-your-own-signer (source_type: "none")
- Two headers, not one. Unlike Stripe / Langfuse — which pack the timestamp and HMAC into a single
t=<unix>,v1=<hmac>value — thenonescheme uses a bare hex HMAC inX-Satsignal- Signatureand carries the timestamp in a separateX-Satsignal-Timestampheader.X-Satsignal-Signature: <hex>— the raw lowercase hex digest, not=/v1=prefixes.X-Satsignal-Timestamp: <unix>— integer unix seconds.
- Canonicalization rule. Your client signs
HMAC-SHA256(signing_secret, "<unix_ts>." + raw_body)and sends the hex digest inX-Satsignal-Signatureand the same<unix_ts>inX-Satsignal-Timestamp. The timestamp is part of the signed message, so the two headers must agree. - Replay window. The timestamp must be within 300s of the notary's clock (same default tolerance as Stripe); outside that the delivery is rejected.
- Signing secret. The notary generates it on webhook provisioning. The create response (and
GET /api/v1/webhooks/wh_...) reveals it once; store it in your client config. - When to use. Your own app emitting outbound events that need anchoring, where you control both ends and can install a signing secret in your outbound code.
Example outbound (Python):
import hmac, hashlib, time, urllib.request, json, os
SECRET = os.environ["SATSIGNAL_WEBHOOK_SECRET"].encode()
URL = "https://app.satsignal.cloud/api/v1/webhooks/wh_abc"
body = json.dumps({"event": "order.completed",
"id": "evt_xyz"}).encode("utf-8")
t = str(int(time.time()))
mac = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
URL,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
# Bare hex HMAC — NOT t=...,v1=... — plus a separate
# timestamp header. Both are required.
"X-Satsignal-Signature": mac,
"X-Satsignal-Timestamp": t,
},
)
with urllib.request.urlopen(req) as resp:
print(resp.status, resp.read())
Manage all of these configs at /w/<workspace>/webhooks in the dashboard. Where this fits relative to other paths:
- For files you already have on disk, use Files.
- For agent runs, use Agents — webhook delivery from an agent runtime works, but the session pattern gives you policy snapshots + manifest binding.
- For tabular batches, see Manifest.
- For "what exactly counts as the bytes to anchor", see What to hash.
Questions about this specification? Email hello@satsignal.cloud.