Changes
Comparing empty → 160dc49.
| @@ -1,0 +1,79 @@ | ||
| 1 | +--- | |
| 2 | +title: Webhooks | |
| 3 | +sort: 4 | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# Webhooks | |
| 7 | + | |
| 8 | +ZephyrCart pushes events to endpoints you register. Endpoints must respond `2xx` within 10 seconds | |
| 9 | +to count as delivered. | |
| 10 | + | |
| 11 | +## Register an endpoint | |
| 12 | + | |
| 13 | +```bash | |
| 14 | +curl -sS https://api.zephyrcart.io/v1/webhook_endpoints \ | |
| 15 | + -H "Authorization: Bearer $ZEPHYR_API_KEY" \ | |
| 16 | + -d '{ | |
| 17 | + "url": "https://hooks.example.com/zephyr", | |
| 18 | + "events": ["order.created", "order.refunded"], | |
| 19 | + "description": "ERP bridge — production" | |
| 20 | + }' | |
| 21 | +``` | |
| 22 | + | |
| 23 | +The response includes a `signing_secret` starting `whsec_…`. Store it; you'll need it to verify | |
| 24 | +signatures. | |
| 25 | + | |
| 26 | +## Verify signatures | |
| 27 | + | |
| 28 | +Every delivery carries: | |
| 29 | + | |
| 30 | +- `X-ZephyrCart-Delivery-Id` — unique ULID per delivery | |
| 31 | +- `X-ZephyrCart-Delivery-Attempt` — `1` on first send, increments on retry | |
| 32 | +- `X-ZephyrCart-Timestamp` — Unix seconds, used in the signature | |
| 33 | +- `X-ZephyrCart-Signature` — `t=<ts>,v1=<hex-hmac-sha256>` | |
| 34 | + | |
| 35 | +The signed string is `<timestamp>.<raw-request-body>`. Compute HMAC-SHA256 with the signing secret | |
| 36 | +and compare in constant time: | |
| 37 | + | |
| 38 | +```python | |
| 39 | +import hmac, hashlib, time | |
| 40 | + | |
| 41 | +def verify(payload: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool: | |
| 42 | + parts = dict(p.split("=", 1) for p in header.split(",")) | |
| 43 | + ts, sig = int(parts["t"]), parts["v1"] | |
| 44 | + if abs(time.time() - ts) > tolerance_seconds: | |
| 45 | + return False | |
| 46 | + expected = hmac.new( | |
| 47 | + secret.encode(), | |
| 48 | + f"{ts}.".encode() + payload, | |
| 49 | + hashlib.sha256, | |
| 50 | + ).hexdigest() | |
| 51 | + return hmac.compare_digest(expected, sig) | |
| 52 | +``` | |
| 53 | + | |
| 54 | +A request that fails verification must be rejected with HTTP `401`. Do not log the body. | |
| 55 | + | |
| 56 | +## Retries | |
| 57 | + | |
| 58 | +Deliveries retry on any `non-2xx` or timeout, with exponential backoff capped at 24 hours total — | |
| 59 | +roughly `1m, 5m, 15m, 1h, 3h, 6h, 12h`, then give up. Persistent failures put the endpoint in | |
| 60 | +`status=failing` after 24 hours; the dashboard shows the last 100 delivery attempts. | |
| 61 | + | |
| 62 | +## Event catalogue | |
| 63 | + | |
| 64 | +| Event | When fired | | |
| 65 | +|--------------------------------|--------------------------------------------------| | |
| 66 | +| `cart.created` | New cart | | |
| 67 | +| `cart.updated` | Line items, addresses, or coupons changed | | |
| 68 | +| `cart.abandoned` | No activity for 60 minutes | | |
| 69 | +| `checkout.session.created` | Hosted checkout URL minted | | |
| 70 | +| `checkout.session.completed` | Payment authorised | | |
| 71 | +| `order.created` | Placed; first webhook after `checkout.completed` | | |
| 72 | +| `order.updated` | Mutable-field change | | |
| 73 | +| `order.refunded` | Each refund, full or partial | | |
| 74 | +| `order.cancelled` | Before fulfilment | | |
| 75 | +| `customer.created` | | | |
| 76 | +| `customer.merged` | `target` is folded into `source` | | |
| 77 | +| `product.created` | | | |
| 78 | +| `product.archived` | Soft-delete | | |
| 79 | +| `inventory.low` | Configurable threshold per product | | |