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