---
title: Webhooks
sort: 4
---

# Webhooks

ZephyrCart pushes events to endpoints you register. Endpoints must respond `2xx` within 10 seconds
to count as delivered.

## Register an endpoint

```bash
curl -sS https://api.zephyrcart.io/v1/webhook_endpoints \
  -H "Authorization: Bearer $ZEPHYR_API_KEY" \
  -d '{
    "url": "https://hooks.example.com/zephyr",
    "events": ["order.created", "order.refunded"],
    "description": "ERP bridge — production"
  }'
```

The response includes a `signing_secret` starting `whsec_…`. Store it; you'll need it to verify
signatures.

## Verify signatures

Every delivery carries:

- `X-ZephyrCart-Delivery-Id` — unique ULID per delivery
- `X-ZephyrCart-Delivery-Attempt` — `1` on first send, increments on retry
- `X-ZephyrCart-Timestamp` — Unix seconds, used in the signature
- `X-ZephyrCart-Signature` — `t=<ts>,v1=<hex-hmac-sha256>`

The signed string is `<timestamp>.<raw-request-body>`. Compute HMAC-SHA256 with the signing secret
and compare in constant time:

```python
import hmac, hashlib, time

def verify(payload: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = int(parts["t"]), parts["v1"]
    if abs(time.time() - ts) > tolerance_seconds:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + payload,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)
```

A request that fails verification must be rejected with HTTP `401`. Do not log the body.

## Retries

Deliveries retry on any `non-2xx` or timeout, with exponential backoff capped at 24 hours total —
roughly `1m, 5m, 15m, 1h, 3h, 6h, 12h`, then give up. Persistent failures put the endpoint in
`status=failing` after 24 hours; the dashboard shows the last 100 delivery attempts.

## Event catalogue

| Event                          | When fired                                       |
|--------------------------------|--------------------------------------------------|
| `cart.created`                 | New cart                                         |
| `cart.updated`                 | Line items, addresses, or coupons changed        |
| `cart.abandoned`               | No activity for 60 minutes                       |
| `checkout.session.created`     | Hosted checkout URL minted                       |
| `checkout.session.completed`   | Payment authorised                               |
| `order.created`                | Placed; first webhook after `checkout.completed` |
| `order.updated`                | Mutable-field change                             |
| `order.refunded`               | Each refund, full or partial                     |
| `order.cancelled`              | Before fulfilment                                |
| `customer.created`             |                                                  |
| `customer.merged`              | `target` is folded into `source`                 |
| `product.created`              |                                                  |
| `product.archived`             | Soft-delete                                      |
| `inventory.low`                | Configurable threshold per product               |
