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

# Webhooks

> Let HaulStow notify your server when a delivery changes.

A webhook is an HTTP request that HaulStow sends to your server. Instead of asking “has this delivery changed?” every 10 seconds, your server receives an update when the change happens.

```text theme={null}
Delivery changes -> HaulStow sends POST -> Your webhook endpoint
```

Start with [polling](/polling) if your backend is not publicly reachable yet. Add webhooks before production when possible.

## Set up your first webhook

1. Create a public HTTPS `POST` endpoint in your backend, for example `https://api.example.com/webhooks/haulstow`.
2. In the HaulStow developer portal, open your application and add a webhook endpoint.
3. Choose the **test** environment and the events you want.
4. Copy the signing secret beginning with `whsec_`. It is shown only once.
5. Save the secret in your server-side secret manager.
6. Use the portal's test button and confirm that your endpoint returns a `2xx` response.

<Warning>
  A webhook signing secret is different from an API key. The API key authenticates requests you send to HaulStow. The webhook secret verifies requests HaulStow sends to you. Never put either secret in frontend code.
</Warning>

## Events

| Event                 | Meaning                                     |
| --------------------- | ------------------------------------------- |
| `delivery.created`    | HaulStow accepted the delivery.             |
| `delivery.assigned`   | A rider or operational path was assigned.   |
| `delivery.picked_up`  | The package was picked up.                  |
| `delivery.in_transit` | The package is moving to the recipient.     |
| `delivery.delivered`  | The delivery finished successfully.         |
| `delivery.failed`     | The in-transit delivery failed.             |
| `delivery.cancelled`  | The delivery was cancelled before pickup.   |
| `delivery.outsourced` | HaulStow marked the delivery as outsourced. |
| `webhook.test`        | A test message requested from the portal.   |

```json theme={null}
{
  "id": "evt_47351d65d53840ce8f4ff7d6d83f1be9",
  "type": "delivery.delivered",
  "api_version": "2026-08-20",
  "environment": "test",
  "created_at": "2026-08-20T14:30:00Z",
  "sequence": 5,
  "data": {
    "delivery": {
      "id": "dlv_3f1a821cdb58498cbb52f4706545a089",
      "external_id": "shop-order-1842",
      "reference": "H-RS83223",
      "environment": "test",
      "simulated": true,
      "status": "delivered",
      "currency": "GHS",
      "delivery_fee": 20,
      "requires_custom_quote": false,
      "tracking_url": null,
      "created_at": "2026-08-20T14:20:00Z",
      "updated_at": "2026-08-20T14:30:00Z"
    }
  }
}
```

The useful delivery snapshot is inside `data.delivery`.

Save the top-level event `id`. HaulStow may send the same event more than once, so use this ID to detect duplicates. The `sequence` number increases for one delivery. If you already applied sequence 5, do not move your local status backwards when sequence 4 arrives late.

## Verify the signature

Each request includes:

```http theme={null}
Haulstow-Signature: t=1787236200,v1=<current-hex-digest>[,v1=<previous-hex-digest>]
Haulstow-Event-Id: evt_...
Haulstow-Delivery-Id: whd_...
User-Agent: Haulstow-Webhooks/1.0
Content-Type: application/json
```

The signature proves that the request came from someone who knows your webhook secret and that the body was not changed in transit.

You must verify the signature against the **exact bytes received from the network**. Verify before parsing JSON. Parsing and then re-encoding JSON can change spaces or key order and make a valid signature fail.

```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyHaulstowWebhook(rawBody, signatureHeader, secret) {
  const fields = signatureHeader.split(",").map((part) => part.split("=", 2));
  const timestamp = Number(fields.find(([name]) => name === "t")?.[1]);
  const signatures = fields.filter(([name]) => name === "v1").map(([, value]) => value);

  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false;
  }

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  const expected = Buffer.from(createHmac("sha256", secret).update(signed).digest("hex"), "hex");

  return signatures.some((signature) => {
    const supplied = Buffer.from(signature, "hex");
    return supplied.length === expected.length && timingSafeEqual(supplied, expected);
  });
}
```

Here is a minimal Express route using that function:

```javascript Node.js theme={null}
import express from "express";
import { verifyHaulstowWebhook } from "./verify-haulstow-webhook.js";

const app = express();

app.post(
  "/webhooks/haulstow",
  express.raw({ type: "application/json" }),
  async (request, response) => {
    const signature = request.header("Haulstow-Signature") ?? "";
    const secret = process.env.HAULSTOW_WEBHOOK_SECRET;

    if (!secret || !verifyHaulstowWebhook(request.body, signature, secret)) {
      return response.sendStatus(400);
    }

    const event = JSON.parse(request.body.toString("utf8"));

    // Save event.id before doing slow work. A UNIQUE database constraint on
    // event.id is a simple way to make duplicate deliveries harmless.
    await saveEventIfNew(event);

    return response.sendStatus(204);
  },
);
```

<Note>
  Register the raw-body parser on the webhook route before a global JSON parser. In frameworks such as Next.js, NestJS, Laravel, Django, or Rails, use the framework's documented raw-request-body feature.
</Note>

Also check that the timestamp is no more than five minutes old. This reduces replay attacks. Compare HMAC values with a constant-time function such as Node's `timingSafeEqual`.

During secret rotation, a callback may contain two `v1` values for 24 hours. Keep the previous secret during that overlap and accept the request when either the current or previous secret validates its matching signature.

## Delivery and retries

HaulStow delivers webhooks **at least once**, which means duplicates are normal and your handler must be safe when they happen.

Return `2xx` only after you have saved the event or placed it on a durable queue. Do not wait for slow work such as sending email or updating several services.

Network errors, timeouts, `408`, `425`, `429`, and `5xx` responses retry. Other `4xx` responses stop automatic retries and require a manual replay from the portal.

Retries occur immediately, then at approximately 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, and 24 hours, with jitter. `Retry-After` is honored for `429` and `503` when it fits within the retry window.

Perform slow work asynchronously after persisting the event. A manual replay keeps the event ID but receives a new `Haulstow-Delivery-Id`.

## Debugging checklist

If no webhook arrives:

1. Confirm the endpoint is active in the correct test/live environment.
2. Confirm its URL is public HTTPS and uses port 443 or 8443.
3. Confirm the event type is selected.
4. Check attempt history in the developer portal.
5. Make sure your firewall accepts HaulStow's request and your route accepts `POST`.

If signature verification fails:

1. Confirm you used the webhook's `whsec_...` secret, not an API key.
2. Confirm the correct environment's secret is loaded.
3. Verify against the raw body before JSON parsing.
4. Parse every `v1` value during a rotation overlap.
5. Make sure the server clock is accurate.
