# Webhooks

Webhooks will send events to your own systems as they happen: status changes, milestones, new ETAs, risk changes and exceptions. Every delivery is signed, so you can check it came from Shipment Insight. Planned.

> **Preview:** Webhooks are not available yet. This page describes the planned design. Event names and fields may still change before launch.

## How it works

1. In **Settings**, add an endpoint: an HTTPS address on your side, and the events you want.
2. Copy the endpoint's **signing secret**. It's shown once.
3. When an event happens, Shipment Insight sends a `POST` with a JSON body to your endpoint.
4. Your endpoint checks the signature, stores the event and answers `2xx` within 10 seconds.

## Event types

| Event | Sent when |
| --- | --- |
| `shipment.lifecycle.changed` | A shipment changes status, for example from **In transit** to **At delivery**. |
| `tracking.milestone.recorded` | A milestone is recorded on the timeline, such as arrival at pickup. |
| `intelligence.eta.updated` | The predicted arrival for a stop changes. |
| `intelligence.risk.updated` | The risk of missing the appointment changes, with the new reasons. |
| `tracking.exception.raised` | A shipment needs attention and appears in **Exceptions**. |

```json
{
  "id": "evt_01HZX5B7K3",
  "type": "intelligence.eta.updated",
  "created_at": "2026-11-07T15:42:10Z",
  "data": {
    "shipment_id": "shp_0123456789",
    "stop_id": "stp_02",
    "eta": "2026-11-07T18:40:00Z",
    "previous_eta": "2026-11-07T18:05:00Z"
  }
}
```

## Signatures

Each request carries a `SI-Signature` header with a timestamp and a signature:

```http
SI-Signature: t=1762530130,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`v1` is an HMAC-SHA256, in hex, of the string `<timestamp>.<body>`, computed with your endpoint's signing secret. `<body>` is the raw request body, exactly as received, before any JSON parsing.

1. Read `t` and `v1` from the header.
2. Reject the request if `t` is more than 5 minutes from your clock. This blocks replayed requests.
3. Compute the HMAC of `t` + `.` + raw body with your secret.
4. Compare it with `v1` using a constant-time comparison. If they differ, answer `400` and ignore the event.

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

const secret = process.env.SI_WEBHOOK_SECRET;
const app = express();

app.post("/webhooks/shipmentinsight", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("SI-Signature") ?? "";
  const pairs = header.split(",").map((p) => p.split("=", 2));
  const ts = pairs.find(([k]) => k === "t")?.[1] ?? "";
  const sigs = pairs.filter(([k]) => k === "v1").map(([, v]) => v); // two during secret rotation

  // Reject old or future timestamps (replay protection).
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(req.body).digest("hex");
  const a = Buffer.from(expected);
  const valid = sigs.some((s) => {
    const b = Buffer.from(s);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  // Store the event, then process it in the background.
  res.sendStatus(200);
});
```

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var secret = []byte(os.Getenv("SI_WEBHOOK_SECRET"))

func Handle(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	var ts string
	var sigs []string // two during secret rotation
	for _, part := range strings.Split(r.Header.Get("SI-Signature"), ",") {
		if k, v, ok := strings.Cut(part, "="); ok {
			switch k {
			case "t":
				ts = v
			case "v1":
				sigs = append(sigs, v)
			}
		}
	}

	// Reject old or future timestamps (replay protection).
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		http.Error(w, "bad timestamp", http.StatusBadRequest)
		return
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	valid := false
	for _, s := range sigs {
		if hmac.Equal([]byte(expected), []byte(s)) {
			valid = true
		}
	}
	if !valid {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}

	// Store the event, then process it in the background.
	w.WriteHeader(http.StatusOK)
}
```

## Retries

A delivery succeeds when your endpoint answers with a `2xx` status within 10 seconds. Anything else (an error, a timeout, a redirect) is retried with growing delays, from seconds to hours, for up to **24 hours**. After that the event is marked as failed, and you can see it and send it again from **Settings**.

> **TIP:** Answer quickly. Store the event and return `200` first, then do the slow work in the background. A slow endpoint causes timeouts, and timeouts cause retries.

## Handle each event once

- The same event can arrive more than once, for example after a timeout. Use the event `id` to skip events you've already processed.
- Events can arrive out of order. Compare `created_at`, or fetch the latest state from the API, before overwriting newer data with older data.
- Ignore event types and fields you don't know. New ones are added over time.

## Security checklist

- Use HTTPS. Endpoints without TLS are not accepted.
- Always verify the signature. Don't rely on IP addresses alone.
- Keep the signing secret in a secrets manager. Rotate it in **Settings** if it leaks.
- Treat text fields in events (notes, references) as untrusted input.