# Guide to Stuart Webhooks: Features and Best Practices

Stuart webhooks report each step of a delivery as it happens: the package is created, a courier is assigned, the courier arrives at pickup, collects the package, reaches the drop-off and delivers it, or the delivery is cancelled or returned. If you book deliveries through Stuart, the on-demand last-mile delivery company, webhooks are how that progress reaches your order system and customer notifications. Stuart says plainly that webhooks, rather than polling the API, are how you stay inside its rate limits.

This guide covers how Stuart subscriptions and their authentication header work, the version 3 topics and payload shape, what Stuart does and doesn't document about retries, and the best practices for running a Stuart receiver in production.

## What are Stuart webhooks?

A Stuart webhook subscription has a URL, a payload version and a list of topics. When a package changes state, Stuart sends a JSON `POST` to the URL for every topic the subscription includes. An account can have at most six subscriptions, and each one holds topics of a single version.

Version 3 is the current format. Version 2, with topics such as `job/update` and `delivery/update`, is deprecated, and its bodies have a different shape. This guide covers v3.

Stuart does not sign webhooks. Instead, you choose a **Webhook authentication header**, whose name must start with `X-`, and a **Webhook authentication key**. Stuart adds that header, with that value, to every request it sends. Your receiver authenticates a request by comparing the header with the value you configured.

## Stuart webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Stuart admin page (Sandbox and Production are separate), or `POST /v2/webhooks` on the Stuart API |
| Subscriptions | Up to 6 per account, each with a URL, a version and a list of topics |
| Payload version | `v3` (current). v2 is deprecated |
| Method and format | `POST` with a JSON body |
| Authentication | A header you name (it must start with `X-`) carrying a key you choose. No signature |
| Event identifier | `topic` in the body |
| Idempotency key | `event_id`, which stays the same when Stuart retries |
| Retries | "Up to 5 times" after an unsuccessful reception. The success rule, timeout and interval are not documented |
| Source IPs | Published for Sandbox and Production, but Stuart recommends relying on DNS rather than IP allowlisting |
| Replay protection | None. No timestamp header is sent |

## Common events

Stuart documents 12 version 3 topics:

| Topic | Fires when |
| --- | --- |
| `package_created` | A package is created |
| `courier_assigned` | A courier is assigned to the package |
| `courier_arriving` | The courier is on the way to the pickup or drop-off |
| `courier_waiting` | The courier is waiting at the pickup or drop-off |
| `package_delivering` | The courier has collected the package at pickup |
| `courier_moving` | The courier is en route from pickup to drop-off |
| `package_delivered` | The delivery is completed |
| `package_canceled` | The package is cancelled |
| `package_returning` | A return to the origin has been requested |
| `package_returned` | The return is completed |
| `courier_reassigning` | The delivery has been reassigned to another courier |
| `package_split` | The package is split into two or more packages for several couriers |

There is one inconsistency to plan for. The webhook-creation endpoint gives a "full list of events you can subscribe to" with only ten topics, leaving out `package_split` and `courier_moving`, while the webhook events section documents all twelve with examples. Stuart's docs don't settle whether those two can be subscribed to, so test in Sandbox before you rely on them.

Every v3 body has the same envelope. Here is Stuart's `package_delivered` example:

```json
{
  "version": "v3",
  "occurred_at": "2023-05-02T16:08:57.000+02:00",
  "event_id": "ab1f8bcc-7eb8-48e7-9ba6-5f0c720dd231",
  "webhook_id": 2515,
  "topic": "package_delivered",
  "details": {
    "account": { "id": 451871 },
    "package": {
      "id": "10000000001",
      "reference": "1683036378",
      "client_tracking_url": "https://stuart.sandbox.followmy.delivery/...",
      "end_customer_tracking_url": "https://stuart.sandbox.followmy.delivery/..."
    }
  }
}

```

`details.package.reference` is the client reference you set on the job, which is usually the easiest way to join an event back to your own order. The other topics add their own objects to `details`: `courier` on `courier_assigned`, `task` (`pickup` or `dropoff`) on the courier arriving, waiting and moving topics, `proximity_radius` on `courier_arriving`, `coordinates` on `courier_moving`, `cancelation` on `package_canceled`, `return` on the return topics, `reassigning` on `courier_reassigning`, and `split` and `new_package` on `package_split`. Note the spelling: the examples use `cancelation`, with one "l", while the topic name is `package_canceled`.

Stuart's payload fields table spells the timestamp `occured_at`, but every example body sends `occurred_at`. Read the field defensively until you have seen a live delivery.

## Setting up Stuart webhooks

First, generate a long random value for the key, for example with `openssl rand -hex 32`, and pick a header name such as `X-Webhook-Secret`.

**In the Stuart admin page.** Open the webhook settings for Sandbox or Production, add your HTTPS URL, choose version `v3` and the topics you need, and enter the **Webhook authentication header** and **Webhook authentication key**.

**Through the API.** Create the subscription with `POST /v2/webhooks`, passing the header and key in `authentication_header` and `authentication_key`:

```bash
curl -X POST "https://api.sandbox.stuart.com/v2/webhooks" \
  -H "Authorization: Bearer $STUART_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/stuart",
    "version": "v3",
    "topics": ["package_created", "courier_assigned", "package_delivered", "package_canceled"],
    "enabled": true,
    "authentication_header": "X-Webhook-Secret",
    "authentication_key": "YOUR_LONG_RANDOM_VALUE"
  }'

```

The management API also has `GET /v2/webhooks` and `GET`, `PUT` and `DELETE` on `/v2/webhooks/{id}`, so you can rotate the key or change topics with a `PUT`.

**In Hookdeck.** Create a source with the Stuart type. Enter the same header name in **Header Name** and the exact key in **Header Value**, then use the source URL as the subscription URL in Stuart. Hookdeck matches the header name case-insensitively and the value exactly, so if you typed `Bearer abc123` as the key, enter `Bearer abc123`.

## Securing Stuart webhooks

With no signature, authenticating a Stuart request means checking one header. Read the header you configured, compare its whole value with your stored key in constant time, and reject the request if it is missing or different. Don't strip or parse a prefix: the value Stuart sends is exactly what you entered.

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

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on buffers of different lengths
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/stuart', express.raw({ type: '*/*' }), (req, res) => {
  const headerName = (process.env.STUART_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
  const expected = process.env.STUART_WEBHOOK_SECRET;
  if (!expected) return res.sendStatus(500); // fail closed when unconfigured

  const received = req.get(headerName);
  if (typeof received !== 'string' || !safeEqual(received, expected)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.event_id, then route on event.topic. Process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_stuart_request(headers) -> bool:
    expected = os.environ.get("STUART_WEBHOOK_SECRET")
    if not expected:
        return False  # fail closed
    header_name = os.environ.get("STUART_WEBHOOK_HEADER", "X-Webhook-Secret")
    received = headers.get(header_name)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on non-ASCII str input.
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

```

Be clear about what this check proves. A matching header shows the sender knows the key. It says nothing about whether the body was changed, and a captured request can be sent again later and still pass. Keep the endpoint on HTTPS so the key can't be read in transit, make the key long and random, and rotate it in Stuart and in your configuration if it may have leaked.

Stuart also publishes its source IPs: `34.254.62.41`, `54.194.139.211` and `54.195.221.43` for Sandbox, and `108.128.110.19`, `54.171.243.90` and `52.51.60.65` for Production. Stuart itself recommends relying on DNS rather than an IP allowlist, so treat the list as a second layer at most.

> **Make Stuart webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the authentication header on every Stuart request, queues each delivery durably, and lets you replay anything your service failed to process.

## Stuart webhook limitations and pain points

### A static key instead of a signature

**The Problem:** The only proof a request came from Stuart is a fixed header value. It can't detect a modified body or a replayed request.

**Why It Happens:** Stuart's authentication is a key-value pair you set once and Stuart repeats on every request. Nothing is computed over the body, and no timestamp is sent.

**Workarounds:**

* Use a long random key, serve the endpoint over HTTPS only, and keep the key out of logs.
* Deduplicate on `event_id` so a replayed delivery does nothing the second time.
* For actions that matter, such as refunding a cancelled order, re-read the job from the Stuart API before acting.

**How Hookdeck Can Help:** Hookdeck's Stuart source type compares the header you name with the exact value you enter. Hookdeck answers Stuart with a `200` either way, then records a request whose header is missing or wrong as failed verification, so it never reaches your destination.

### Retries are thinly documented

**The Problem:** You can't plan an outage budget, because you don't know how long Stuart keeps trying.

**Why It Happens:** Stuart says that after an unsuccessful reception it "can attempt to retry sending the same webhook event up to 5 times", but it doesn't say what counts as success, how long it waits for a response, or how far apart the retries are.

**Workarounds:**

* Return `200` as soon as the header checks out, and do the work afterwards.
* Treat anything Stuart sends while you are down for a long time as possibly lost, and reconcile open jobs against the Stuart API after an incident.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives and stores it, then retries delivery to your service on a schedule you set. You can replay any event after a fix, so an outage on your side doesn't depend on Stuart's five attempts.

### Two topics may not be subscribable

**The Problem:** You build handling for `courier_moving` or `package_split` and never receive either.

**Why It Happens:** The webhook-creation docs list ten subscribable topics. The webhook events section documents twelve, including those two. The docs don't say which is right.

**Workarounds:**

* Subscribe to both in Sandbox and check that events arrive before depending on them in Production.
* Acknowledge any topic you don't recognise with a `200` rather than an error.

**How Hookdeck Can Help:** Every request that reaches a Hookdeck source is logged with its full body, so you can see exactly which topics Stuart sends to each subscription.

### `courier_moving` is high volume

**The Problem:** A handler sized for a handful of events per delivery gets a steady stream of location updates.

**Why It Happens:** `courier_moving` reports the courier's coordinates while they travel from pickup to drop-off. Stuart sends it every 30 seconds if the courier has moved since the last update, so it fires repeatedly for every active delivery.

**Workarounds:**

* Only subscribe to `courier_moving` if you show live tracking. Stuart's `client_tracking_url` and `end_customer_tracking_url` may be enough on their own.
* Keep the newest position per package and drop older ones, rather than storing every update.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can send `courier_moving` to a separate destination, and rate limits on that destination protect the rest of your system from bursts.

## Best practices

### Compare the whole header value in constant time

Store the exact value you configured in Stuart, look the header up case-insensitively, and compare it with a length check and `crypto.timingSafeEqual`, or `hmac.compare_digest` in Python. Return `401` when it's missing or wrong.

### Deduplicate on `event_id`

Stuart keeps the same `event_id` when it retries an event. Record each one before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Join on your own reference

`details.package.reference` carries the client reference you set when creating the job. Using it avoids a lookup from Stuart's package ID to your order.

### Don't assume order

Retries and the courier topics can arrive out of sequence. Compare `occurred_at` with the last state you stored before overwriting it, so a late `courier_arriving` never replaces `package_delivered`.

### Acknowledge fast and process asynchronously

Verify the header, store the event, return `200`, and do the real work in a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Expect the payload to grow

Stuart's v3 usage guidelines say new topics and new attributes may be added, and that field lengths may change. Ignore unknown fields and topics instead of failing on them.

## Conclusion

Stuart sends a JSON `POST` for each delivery topic you subscribe to, with the `X-` header and key you chose attached as its only credential. Compare that header in constant time over HTTPS, deduplicate on `event_id`, route on `topic`, and test `courier_moving` and `package_split` in Sandbox before depending on them.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the authentication header, and stores every Stuart event until your service has processed it, so a slow deploy or an outage costs you latency rather than lost deliveries.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Stuart webhooks reliably in minutes.