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:
{
"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:
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.
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:
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 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_idso 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
200as 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
200rather 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_movingif you show live tracking. Stuart'sclient_tracking_urlandend_may be enough on their own.customer_ tracking_ url - Keep the newest position per package and drop older ones, rather than storing every update.
How Hookdeck Can Help: Hookdeck 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.
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.
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 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 for free and handle Stuart webhooks reliably in minutes.