A file gets hashed. The hash goes off to be anchored. Some time later a webhook arrives saying the anchor landed. The gap between "submitted" and "confirmed" is where most of the hard problems in an async proof pipeline live. Not the hashing. Not the anchoring. The webhook.

I hit all of this building the delivery side of ProofLedger's webhooks, and the same four problems show up in any system where a client starts work and gets told later: payment confirmations, transcoding, exports. Here they are in the order they bite, with the shape ProofLedger's own deliveries take so you can wire a real receiver.

What a ProofLedger delivery looks like

Register an endpoint once, with your API key. The URL has to be public https, and the signing secret is returned once, in this response only.

curl -X POST https://proofledger.io/api/v1/webhooks \
  -H "Authorization: Bearer sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.example.com/proofledger/events"}'

The reply is 201 with endpoint (its id, url, events, is_active) and secret. Store the secret where your receiver can read it. Events are sent only for proofs created after registration, and an account can hold up to 5 active endpoints.

Each delivery is a POST with a JSON body: id (such as evt_8812), type, created_at and data. The event you want is proof.polygon.anchored. data.proof carries the proof summary (id, sha256, external_id, chain_tx, polygon_anchored_at) and data.bundle carries the proof bundle, the same document GET /api/v1/proof/{id}/bundle returns. The signature travels in the ProofLedger-Signature header. POST /api/v1/webhooks/{id}/test sends a signed webhook.test event right now, so you can exercise the receiver before any real proof exists.

Verify the signature before you trust the payload

A webhook endpoint is a URL on the open internet. Anyone can POST to it. If your handler reads type: "proof.polygon.anchored" and acts on it without checking where it came from, a stranger can fake completion.

The fix is HMAC-SHA256: the sender computes a MAC over the request with a shared secret and puts it in a header, and the receiver recomputes it and compares. The general pattern:

import hashlib
import hmac

def verify_hmac(secret: bytes, raw_body: bytes, signature_hex: str) -> bool:
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_hex)

Two details matter, and both are easy to get wrong. First, hmac.compare_digest, not ==. A plain comparison stops at the first mismatched byte, which leaks timing an attacker can use to guess the value byte by byte. Second, sign the raw bytes, never the parsed JSON. If you re-serialise request.get_json(), key order or whitespace will differ from the sender's bytes and the MAC will not match, and the false rejections are miserable to debug.

ProofLedger's header carries a timestamp alongside the MAC, and the MAC covers that timestamp together with the raw body. The verify-proof package on PyPI ships a checker for exactly this header, so a Flask receiver is short:

import os
from flask import Flask, request, abort
from proofledger_api import verify_webhook_signature

app = Flask(__name__)
SECRET = os.environ["PROOFLEDGER_WEBHOOK_SECRET"]

@app.post("/proofledger/events")
def receive():
    header = request.headers.get("ProofLedger-Signature", "")
    if not verify_webhook_signature(SECRET, header, request.get_data()):
        abort(401)
    event = request.get_json()
    process_event(event)
    return "", 204

request.get_data() is the raw body. Pass it through untouched.

Idempotency: the same event will arrive more than once

Senders retry on timeout, because from their side a timeout looks exactly like a dropped delivery. ProofLedger retries a failing endpoint for almost two days (eight attempts, spaced from a minute to a day apart), then switches it off and emails the account. Your handler has to survive the same event two, three, ten times.

The fix is to key side effects on the event's id and enforce uniqueness in storage, not in application logic that can race.

import json
import sqlite3
import time

conn = sqlite3.connect("webhook_events.db")
conn.execute("""
    CREATE TABLE IF NOT EXISTS processed_events (
        event_id TEXT PRIMARY KEY,
        received_at REAL NOT NULL
    )
""")

def claim_event(event_id: str) -> bool:
    cur = conn.execute(
        "INSERT OR IGNORE INTO processed_events (event_id, received_at) VALUES (?, ?)",
        (event_id, time.time()),
    )
    conn.commit()
    return cur.rowcount == 1

def process_event(event: dict) -> None:
    if not claim_event(event["id"]):
        return
    if event["type"] == "proof.polygon.anchored":
        proof_id = event["data"]["proof"]["id"]
        with open(f"proofs/{proof_id}.proof.json", "w") as f:
            json.dump(event["data"]["bundle"], f)

INSERT OR IGNORE against a PRIMARY KEY does the real work: two requests racing on the same event_id have one row inserted and one no-op, decided by the database. A check-then-set in Python has a window a second thread can slip through. A Redis SET NX with a TTL does the same job if that is what you run.

Keying on the proof instead of the event works too, and external_id is the natural key when your own record id went in with the hash. Either way, write the key before the side effect, not after.

Retry and backoff on the sending side

If you are the one firing webhooks, the receiver will sometimes be down, slow, or rate-limiting you. Retrying in a tight loop makes that worse. Exponential backoff with jitter spreads retries out, and a Retry-After header, when present, overrides your schedule with theirs.

import random
import time
import requests

def send_webhook(url: str, body: bytes, headers: dict, max_attempts: int = 5) -> bool:
    for attempt in range(max_attempts):
        try:
            resp = requests.post(url, data=body, headers=headers, timeout=5)
            if resp.status_code < 300:
                return True
            if resp.status_code == 429:
                wait = float(resp.headers.get("Retry-After", 0))
                time.sleep(wait or backoff_delay(attempt))
                continue
            if resp.status_code < 500:
                return False
        except requests.RequestException:
            pass
        time.sleep(backoff_delay(attempt))
    return False

def backoff_delay(attempt: int) -> float:
    base = min(2 ** attempt, 60)
    return base + random.uniform(0, base * 0.1)

The branch on status code is the point. A 429 says when to come back. A 4xx other than 429 means the request itself is wrong and resending it changes nothing. Everything else falls through to backoff. Treating a 400 and a 503 the same wastes retry budget on requests that were never going to succeed. The same shape applies when you call ProofLedger's API: a 429 on a keyed route carries Retry-After in seconds, and the per-key limit is 120 requests a minute.

Replay protection closes the last gap

A valid signature proves the payload came from the real sender. It does not prove the payload is fresh. Capture one signed request (a log, a proxy, a misconfigured cache) and it can be replayed later; the MAC still checks out.

The fix is a timestamp inside the signed material, checked against a tolerance window, on top of the idempotency check you already have. That is why the timestamp lives in the signature header rather than only in the body: strip an old timestamp and paste in a fresh one and the MAC breaks. verify_webhook_signature rejects a delivery whose timestamp is outside its window (tolerance_seconds, 300 by default), which absorbs clock drift between well-behaved systems and leaves a captured request useless a few minutes later.

If you build your own sender, do the same: put the timestamp in the signed material, and reject anything outside the window before you touch storage.

Putting it together

None of the four pieces is hard on its own. Skipping any one leaves a gap the other three do not cover. Signature checks without idempotency let a duplicated real event double-process. Idempotency without a replay window means a captured request stays valid indefinitely. Backoff that ignores Retry-After hammers a struggling server on your schedule instead of theirs.

For anchoring specifically, the shape is fixed: a hash goes out, a confirmation comes back when the chain says so, and your receiver has to be safe whether that confirmation shows up once, three times, or late with a stale timestamp. When the event is real, keep data.bundle next to the file. It is the part of the delivery that still means something if the sender is gone.

To check a proof without ProofLedger, hash the file, compare it with the bundle (verify-proof verify file --proof bundle.json, offline), and look the transaction id up on any Polygon block explorer. API reference: https://proofledger.io/api.html