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

Don't use this when:

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
fieldtyperequiredmeaning
folder_slugstringyesfolder the resulting proofs file under.
source_typestringyesone of stripe, github, langfuse, none. Selects the signature scheme.
signing_secretstringnooptional; 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:

  1. 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.
  2. On verification: the raw body is sha256d as the canonical bytes.
  3. An anchor is created in the webhook's bound folder, with category: "output" and a label of webhook:<source_type> (unless you set a custom label on the webhook config).
  4. 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:

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:

  1. 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.
  2. 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.
  3. Batch the digests and anchor via the manifest path: POST /api/v1/anchors with items: [{label, sha256_hex}, …], up to 10,000 per anchor. Only the {label, sha256_hex} digests leave your infrastructure — never the body.
  4. 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:

  1. The .mbnt bundle — fetch from bundle_url on the anchor response.
  2. 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.
  3. 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

eventbehavior
signature verification fails401 to source; source retries per its own policy. No quota burn.
body parses, anchor in flight200 returned only after the anchor lands.
anchor pipeline 5xx503 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

codenamemeaningwhat to do
400unsupported_source_typesource_type not in the enumuse stripe, github, langfuse, or none
400signing_secret_requiredPATCHing a secret of the wrong format for the source typecheck the source's secret format (whsec_... for Stripe, etc.)
401bad_signaturesignature header missing or doesn't verifycheck the secret is installed; for Stripe / GitHub re-copy from the source UI
401stale_timestampsignature header's timestamp is > 5 minutes from nowsource clock skew; usually self-corrects
404webhook_not_foundwh_id doesn't exist or was deletedre-provision
429rate_limitedper-wh_id 60/min caphonor Retry-After; source should back off
503anchor_pipeline_failedupstream wallet/broadcast failuresource 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")

# 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")

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")

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")

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:

Questions about this specification? Email hello@satsignal.cloud.