# Webhooks

Getting a POST from Horizon when a shipment or a return changes, the events and their payloads, and how to check that a delivery really came from Horizon

Section: api. Audience: public. Updated: 2026-10-10.

---
A webhook is an address on your own server that Horizon calls when something happens: a label is created, a parcel is delivered, a return is approved. Use webhooks instead of polling the API to learn about changes as they happen.

## Adding an endpoint

Endpoints are managed in the app, in Settings, under API keys & webhooks (see [API keys](/app/api-keys/)). The account owner or an admin adds, changes, tests and removes them; every team member can see them. They cannot be managed with an API key.

1. Enter the address of your endpoint. It must be `https`, on the standard port (443), and reachable on the public internet. Addresses on private networks are refused.
2. Tick the events it should receive.
3. Save. The card for the new endpoint shows its **signing secret**. Copy it now and store it with your other secrets: it is shown this once and never again, and nobody at Horizon can look it up for you.

To get a new secret, for example because the old one may have leaked, delete the endpoint and add it again. The new endpoint has a new secret; the old one stops receiving deliveries at once.

The Test button on an endpoint's card sends a signed test delivery straight away and shows the answer your server gave. Pause stops deliveries without deleting the endpoint.

## Events

| Event | Sent when |
|---|---|
| `shipment.created` | A shipment is created, for example when a label is bought. |
| `shipment.label_printed` | The carrier reports the label as created. |
| `shipment.in_transit` | The parcel reaches a carrier hub, or goes out for delivery. |
| `shipment.delivered` | The carrier reports the parcel delivered. |
| `shipment.exception` | The carrier reports a problem, such as a failed delivery attempt. |
| `return.requested` | A return is created. |
| `return.approved` | A return is approved. |
| `return.rejected` | A return is rejected. |
| `return.in_transit` | A return parcel is on its way back. |
| `return.received` | A return is marked received. |
| `return.completed` | A return is completed. |
| `return.cancelled` | A return is cancelled. |

An automation rule with a webhook action sends its own event, named in the rule. It reaches the endpoints subscribed to that name.

## What a delivery looks like

Each delivery is an HTTPS `POST` with a JSON body and these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `X-Horizon-Event` | The event name, for example `shipment.delivered`. |
| `X-Horizon-Delivery` | A unique ID for this delivery attempt. |
| `X-Horizon-Timestamp` | When the attempt was sent, in Unix seconds. |
| `X-Horizon-Signature` | The signature of the body. See [Checking the signature](#checking-the-signature). |

Read the event from the `X-Horizon-Event` header: the tracking events do not repeat it in the body.

### `shipment.created`

```json
{
  "carrier": "ups",
  "event": "shipment.created",
  "service_code": "11",
  "shipment_id": "…",
  "status": "…",
  "tracking_number": "1Z…"
}
```

### `shipment.label_printed`, `shipment.in_transit`, `shipment.delivered`, `shipment.exception`

```json
{
  "carrier": "ups",
  "exception_code": null,
  "normalized_status": "DEL",
  "scan_location_code": "…",
  "scan_status_code": "…",
  "scan_timestamp": "2026-10-10T09:41:00+00:00",
  "shipment_id": "…",
  "tracking_number": "1Z…"
}
```

`normalized_status` is Horizon's own status for the scan: `LABEL`, `HUB`, `OUT`, `DEL` or `EXC`.

### `return.*`

Every return event carries the same body, with `event` set to the event name:

```json
{
  "carrier": "…",
  "channel": "…",
  "completed_at": null,
  "country": "FR",
  "credit_amount": null,
  "currency": "EUR",
  "customer_email": "jane@example.com",
  "event": "return.approved",
  "fee_amount": null,
  "id": 1234,
  "order_name": "#1001",
  "reason": "too_small",
  "refund_amount": null,
  "requested_at": "2026-10-09T14:02:11+00:00",
  "requested_resolution": "…",
  "resolution": "…",
  "rma_number": "…",
  "source": "PORTAL",
  "status": "APPROVED",
  "tracking_number": "…",
  "total_value": "64.00"
}
```

Amounts are strings, to keep their exact decimal value. The body includes your customer's email address, so treat deliveries as personal data.

Horizon adds fields over time. Ignore fields you do not recognise rather than rejecting the delivery.

## Checking the signature

`X-Horizon-Signature` is the HMAC-SHA256 of the **exact bytes of the request body**, keyed with your endpoint's signing secret, written as lowercase hex. Check it on every delivery before you act on it:

1. Read the raw body as bytes, before any JSON parsing. Re-serialising the parsed JSON changes the bytes and the check fails.
2. Compute the HMAC-SHA256 of those bytes with your signing secret.
3. Compare it with the header in constant time. If they differ, answer `401` and ignore the delivery.

Node.js (Express):

```js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/horizon/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.HORIZON_WEBHOOK_SECRET)
    .update(req.body) // the raw Buffer
    .digest("hex");
  const received = req.get("X-Horizon-Signature") || "";
  const ok =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = req.get("X-Horizon-Event");
  const payload = JSON.parse(req.body.toString("utf8"));
  // queue the work, then answer quickly
  res.sendStatus(204);
});
```

Python (Flask):

```python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HORIZON_WEBHOOK_SECRET"].encode()

@app.post("/horizon/webhooks")
def horizon_webhook():
    raw = request.get_data()  # the raw bytes
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Horizon-Signature", "")):
        abort(401)
    event = request.headers["X-Horizon-Event"]
    payload = request.get_json()
    # queue the work, then answer quickly
    return "", 204
```

The signature covers the body only, not the headers. Act on what the body says, not on the timestamp or delivery headers alone.

## Answering, retries and duplicates

- **Answer quickly with a `2xx`.** Horizon waits 10 seconds at most for your answer. Do the real work after you have answered, for example from a queue.
- **Failures that are retried:** no answer, a network error, or a `5xx`. Horizon tries again a few times, waiting longer each time, then gives up on that delivery.
- **Failures that are not retried:** a `4xx`, and any redirect. Horizon never follows a redirect: a `3xx` counts as a failed delivery. An address that no longer passes the address rules is not called at all.
- **The card shows the last outcome.** Each endpoint's card shows whether its last delivery succeeded, with your server's status code.
- **Expect duplicates and gaps.** A retry is a new attempt with a new `X-Horizon-Delivery` ID, so the same change can arrive more than once. Make your handling idempotent on what the body identifies: for a tracking event, the `shipment_id`, `normalized_status` and `scan_timestamp`; for a return, the `id` and `status`. Deliveries can also arrive out of order. When the order matters, read the current state from the [API](/api/).