> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stella-commerce.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Stella pushes events to your endpoint. HMAC-signed, retried with backoff, replayable from the dashboard.

Stella emits HMAC-SHA256-signed webhook events for every meaningful
state change. You point us at a URL, pick events, and your system
gets pinged on every order, customer, inventory, or product change.

The wire format is Stripe-style: `t=<unix>,v1=<hex>` in the
`X-Stella-Signature` header, signed payload is `<timestamp>.<rawBody>`.

## Register an endpoint

In the dashboard at **Settings → Webhooks**:

1. Click **Add endpoint**.
2. URL — your receiver (`https://kasa.com/api/stella/webhooks`).
3. Pick events. The v1 taxonomy:
   * `order.created`, `order.paid`, `order.fulfilled`,
     `order.cancelled`, `order.refunded`
   * `customer.created`, `customer.updated`
   * `inventory.updated`
   * `product.updated`
4. Save. The signing secret (`whsec_…`) is shown **once** — copy it.

## Verify a delivery

Pseudocode (real implementation: see `@stella/webhooks`):

```javascript theme={null}
const signature = req.headers['x-stella-signature']
const [tPart, v1Part] = signature.split(',')
const timestamp = tPart.split('=')[1]
const expected  = v1Part.split('=')[1]

// Reject anything older than 5 minutes (replay defense).
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) {
  return res.status(400).end()
}

const signedPayload = `${timestamp}.${rawBody}`
const computed = crypto
  .createHmac('sha256', WHSEC)
  .update(signedPayload)
  .digest('hex')

if (!crypto.timingSafeEqual(
      Buffer.from(computed),
      Buffer.from(expected))) {
  return res.status(400).end()
}

const event = JSON.parse(rawBody)
// Handle event.type, event.data, event.merchant_id, ...
res.status(200).end()
```

<Note>
  Use `@stella/webhooks` (PR #81) once published — the manual
  implementation above is just to show the wire format.
</Note>

## Retry policy

Failed deliveries (any non-2xx, or timeout > 30s) are retried with
exponential backoff:

```
attempt 1 → +1 minute
attempt 2 → +5 minutes
attempt 3 → +30 minutes
attempt 4 → +2 hours
attempt 5 → +12 hours
```

After the fifth failure, the delivery is marked `failed` and shown
red in the dashboard's delivery log. You can replay it from there.

## Delivery log

Every event surfaces:

* Event type, merchant ID, payload hash
* Each attempt's status, response body, response time, ACAO header
* Replay button per delivery

Replays carry the **original timestamp + signature** — your verifier
should still accept them inside the 5-minute skew window. (If you've
gone past the skew, the dashboard's "Replay with fresh signature"
mints a new signature stamp.)

## Idempotency on your end

Every event carries an `event_id` that's stable across replays. Your
receiver should:

```javascript theme={null}
if (await alreadyProcessed(event.event_id)) {
  return res.status(200).end()  // ack but don't double-process
}
await process(event)
await markProcessed(event.event_id)
res.status(200).end()
```

This protects against duplicate deliveries from retries or operator
replays.
