Webhooks
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). 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.
- 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. - Tick the events it should receive.
- 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. |
Read the event from the X-Horizon-Event header: the tracking events do not repeat it in the body.
shipment.created
{ "carrier": "ups", "event": "shipment.created", "service_code": "11", "shipment_id": "…", "status": "…", "tracking_number": "1Z…"}shipment.label_printed, shipment.in_transit, shipment.delivered, shipment.exception
{ "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:
{ "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:
- Read the raw body as bytes, before any JSON parsing. Re-serialising the parsed JSON changes the bytes and the check fails.
- Compute the HMAC-SHA256 of those bytes with your signing secret.
- Compare it with the header in constant time. If they differ, answer
401and ignore the delivery.
Node.js (Express):
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):
import hashlibimport hmacimport 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 "", 204The 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: a3xxcounts 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-DeliveryID, so the same change can arrive more than once. Make your handling idempotent on what the body identifies: for a tracking event, theshipment_id,normalized_statusandscan_timestamp; for a return, theidandstatus. Deliveries can also arrive out of order. When the order matters, read the current state from the API.