checkout402_
Guides

Webhooks#

Get told when you've been paid, instead of polling.

Register an endpoint#

curl -X POST https://api.checkout402.com/v1/webhooks \
  -H "Authorization: Bearer $C402_KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://yourco.com/hooks/checkout402"}'
{
  "id": "wh_fov8_997ae7UpA",
  "url": "https://yourco.com/hooks/checkout402",
  "events": ["checkout.paid"],
  "secret": "whsec_...",
  "note": "store the secret now — verify deliveries with it"
}

The secret is shown once

Like an API key. If you lose it, register a new endpoint — you cannot read it back.

Verify every delivery#

Each request carries a signature header:

X-Checkout402-Signature: t=1785226440,v1=<hmac-sha256>

The signed value is "{t}.{raw_body}", keyed with your webhook secret.

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, sig = parts["t"], parts["v1"]

    # Reject stale timestamps BEFORE comparing, or a captured delivery can be
    # replayed against you forever.
    if abs(time.time() - int(t)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)   # constant-time, not ==

Two ways to get this wrong

Verify against the raw bytes, not a re-serialized JSON object — re-encoding changes whitespace and key order, and the signature won't match. And compare with compare_digest; a plain == leaks the answer through timing.

Delivery behaviour#

Retries Automatic, with backoff, via a sweep
Ordering Not guaranteed — use the payment id
Duplicates Possible. Make your handler idempotent on payment.id
Success Any 2xx. Anything else is retried

Respond fast and do the work afterwards. A slow handler looks like a failure and earns you a retry.

Single-writer sweep

The retry sweep assumes one instance. If you self-host, run one web instance — see the deploy notes in GO_LIVE.md.

Don't trust the payload alone#

The webhook body tells you a payment happened. The receipt is the authority, and the chain is the authority behind that:

r = httpx.get(f"https://api.checkout402.com/r/{payload['payment']['id']}")
receipt = r.json()
# receipt["tx_hash"] is on-chain and verifiable without trusting anyone

For anything that releases real value, check the receipt — or the transaction itself — before acting.

Alternative: just poll#

For low volume, polling is honestly fine and has fewer moving parts:

c402 checkouts list --json | jq '.checkouts[] | select(.status == "paid")'