# Stuart Webhooks

Stuart sends a JSON webhook to your endpoint as each package moves through a delivery: created,
courier assigned, courier arriving, delivered, canceled and so on. Webhooks are **not signed**.
Instead you choose a *Webhook authentication header* (its name must start with `X-`) and a
*Webhook authentication key* in the Stuart dashboard or webhook API, and Stuart adds that one
header to every request. Your endpoint authenticates the request by comparing it with the value
you configured.

> **A static shared credential authenticates the sender, not the body, and gives no replay
> protection** (there is no signature and no timestamp). Serve the endpoint over HTTPS, keep the
> value secret, and rotate it in Stuart and in your config if it leaks. Use `event_id` to
> deduplicate.

## When to Use This Skill

* How do I receive Stuart webhooks?
* How do I verify the Stuart webhook authentication header and key?
* Does Stuart sign webhook payloads? (No: use the shared header value)
* How do I handle `package_created`, `courier_assigned` or `package_delivered` events?
* Why does my Stuart webhook endpoint return 401?

## Verification (core)

```javascript
const crypto = require('crypto');

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(String(a)).digest();
  const hb = crypto.createHash('sha256').update(String(b)).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyStuartRequest(headers, name = process.env.STUART_WEBHOOK_HEADER || 'X-Webhook-Secret',
                             secret = process.env.STUART_WEBHOOK_SECRET) {
  if (!secret) return false;                          // never accept when unconfigured
  const received = headers[name.toLowerCase()];       // Node lowercases header names
  return Boolean(received) && safeEqual(received, secret); // WHOLE value, no 'Bearer ' parsing
}

```

* The header **name** is whatever you entered as the authentication header (must start with
  `X-`); read it from `STUART_WEBHOOK_HEADER` (default `X-Webhook-Secret`). Look it up
  case-insensitively.
* `STUART_WEBHOOK_SECRET` is the exact, whole header value, including any `Bearer ` prefix you
  typed into the authentication key. Compare the whole value; don't strip or parse a prefix.
* Return `401` for a missing header or a mismatch, `400` for a body that isn't a JSON object,
  `200` once handled.
* Read the body raw (`express.raw({ type: '*/*' })`) and authenticate before parsing it.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stuart-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stuart-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stuart-webhooks/examples/fastapi/).

## Event Types (v3 topics)

Version 3 payloads carry `topic`, `event_id`, `webhook_id`, `occurred_at` and a `details` object
(with `details.package.id` and `details.package.reference`).

| Topic | Sent When |
| --- | --- |
| `package_created` | The package is in the Stuart system |
| `courier_assigned` | A courier accepted the package |
| `courier_arriving` | The courier entered the pickup or dropoff geofence (`details.task`) |
| `package_split` | The package was split; `details.new_package.id` is the new package |
| `courier_waiting` | The courier has waited 2 minutes at pickup or dropoff (`details.task`) |
| `package_delivering` | The courier has the package and is heading to the dropoff |
| `package_delivered` | The package was delivered |
| `package_canceled` | The package will not be delivered (`details.cancelation`) |
| `package_returning` | An undeliverable package is on its way back (`details.return`) |
| `package_returned` | The package was returned to the pickup location |
| `courier_reassigning` | The courier was unassigned and a new one will be found (`details.reassigning`) |
| `courier_moving` | Courier coordinates while heading to the dropoff (`details.coordinates`) |

## Environment Variables

```bash
STUART_WEBHOOK_HEADER=X-Webhook-Secret   # the authentication header name you set in Stuart
STUART_WEBHOOK_SECRET=your-secret-value  # the exact authentication key (whole header value)

```

## Local Development

Stuart can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 stuart --path /webhooks/stuart

```

Use the printed URL as the webhook URL in Stuart.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/stuart-webhooks/references/overview.md) - What Stuart webhooks are, topics and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/stuart-webhooks/references/setup.md) - Configure the webhook and authentication header
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/stuart-webhooks/references/verification.md) - Header check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/stuart-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests