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

# Shipment webhooks

> Configure an endpoint and verify shipment status notifications.

Shipment webhooks send signed JSON `POST` requests when OVRSEA observes a change
to a shipment's client-visible status.

## Configure your endpoint

In Hermes, open **API Integration → API → Shipment webhooks**. For Lyseo/Fatton
accounts without the API Integration menu, open **Account settings → Shipment
webhooks**. An account owner or admin can save, replace, remove and test one URL
for their own client account.
The URL receives status events for that client's shipments across ocean, air,
rail and truck. Configuration requires direct access to that exact account;
access through a parent, child or shared account does not grant this permission.
OneChain and logistic-agent accounts cannot configure these webhooks. Accounts
with an existing bespoke webhook integration must arrange its migration first.

Use a publicly reachable HTTPS URL, for example
`https://integrations.example.com/shipment-webhooks`. Credentials in the URL,
query strings and fragments are rejected. Loopback, private, link-local,
metadata and reserved IP addresses are blocked, including addresses returned
by DNS. Redirects are not followed; configure the final destination.

Saving the URL makes it eligible for delivery immediately. Saving it sends no
historical snapshot or backfill. Replacing or removing it cancels pending work
for the former configuration, although a delivery attempt already in progress can finish
at the former URL.

### Keep the signing secret

Creation and secret rotation show the new signing secret once. Copy it directly
to your receiver's secret store. Reading the configuration or replacing its URL
does not reveal or rotate the secret. If you lose it, rotate it. The signing
secret is separate from your REST API token; keep it out of URLs and logs.

Rotation takes effect for future signing attempts, including retries. Each
attempt uses the current secret. A request signed before rotation may still
arrive with the previous key; coordinate receiver updates and, if needed, keep
a short, bounded verification overlap for those requests. Rotation does not
cancel pending deliveries.

## Event and payload

The supported event type is `shipment.status_changed`, with this V1 payload:

```json theme={null}
{
  "schemaVersion": "1",
  "eventId": "694f46e7-4a4e-43a1-a095-516f52ec2141",
  "eventType": "shipment.status_changed",
  "emittedAt": "2026-07-28T10:42:18.000Z",
  "data": {
    "shipmentId": 12345,
    "fileReference": "BB-ABCD-3",
    "status": "in_progress"
  }
}
```

| Field | Meaning |
| - | - |
| `schemaVersion` | Payload version, currently the string `"1"`. |
| `eventId` | UUID identifying this event; unchanged across retries. |
| `eventType` | `shipment.status_changed`. |
| `emittedAt` | UTC time the event was prepared, rather than the HTTP attempt or exact business transition time. |
| `data.shipmentId` | Internal bigint shipment ID represented as a positive safe-integer JSON number, at most `9007199254740991`. |
| `data.fileReference` | Human-readable shipment file reference. |
| `data.status` | `booking_request`, `awaiting_booking`, `booked`, `in_progress`, `arrived`, `finished` or `cancelled`. |

Every field is required. V1 contains no tracking details or previous status.
Cancellation uses `status: "cancelled"` in the same event. Quotation-only states
are excluded. Status observations can fill intermediate progression stages,
but they do not form a complete history of business transitions.

`shipmentId` is not a file reference or the UUID used by the REST shipment and
tracking routes. Those routes do not accept this numeric internal ID.

## Verify each request

Requests use `Content-Type: application/json` and these four webhook headers:

| Header | Value |
| - | - |
| `X-Ovrsea-Webhook-Id` | The payload's `eventId`. |
| `X-Ovrsea-Webhook-Timestamp` | Unix time in seconds for this signing attempt. |
| `X-Ovrsea-Webhook-Mode` | `live` or `test`. |
| `X-Ovrsea-Webhook-Signature` | `v1=` followed by a lowercase hexadecimal HMAC-SHA256 digest. |

Compute HMAC-SHA256 with the signing secret's UTF-8 bytes as the key. Use the
secret's text as shown, even though it looks hexadecimal; do not hex-decode it.
The signed message is the UTF-8 header prefix followed by the exact raw body bytes:

```text theme={null}
<timestamp>.<eventId>.<mode>.<rawBody>
```

Capture the body before JSON parsing. Do not reserialize JSON, change whitespace
or decode and re-encode the body before verifying. Validate the header formats,
compare digests in constant time, then parse the authenticated body and check
that its `eventId` matches the header. The signed mode must be validated too.

We recommend rejecting timestamps more than five minutes in the past or future,
with synchronized receiver clocks. This tolerance is a receiver recommendation,
not a sender policy. Use the header timestamp, not `emittedAt`; retries have a
fresh timestamp and signature for the same event ID and body.

### Node.js verification example

This Node.js example uses Zod for payload validation (`npm install zod`). Pass a
`Headers` instance and a `Buffer` containing the unmodified request body. In a
Node HTTP handler, capture bytes with `buffer(request)` from
`node:stream/consumers` before any JSON middleware; in a handler using a Web
`Request`, use `Buffer.from(await request.arrayBuffer())`. Configure request size
and read-time limits at your ingress or HTTP server.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import { z } from "zod";

const payloadSchema = z.object({
  schemaVersion: z.literal("1"),
  eventId: z.string().uuid(),
  eventType: z.literal("shipment.status_changed"),
  emittedAt: z.string().datetime(),
  data: z.object({
    shipmentId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
    fileReference: z.string().min(1),
    status: z.enum([
      "booking_request", "awaiting_booking", "booked", "in_progress",
      "arrived", "finished", "cancelled",
    ]),
  }).strict(),
}).strict();

const parseWebhookHeaders = ({ headers, nowSeconds }) => {
  const signed = z.object({
    eventId: z.string().uuid(),
    timestamp: z.string().regex(/^(0|[1-9][0-9]*)$/),
    mode: z.enum(["live", "test"]),
    signature: z.string().regex(/^v1=[0-9a-f]{64}$/),
  }).parse({
    eventId: headers.get("X-Ovrsea-Webhook-Id"),
    timestamp: headers.get("X-Ovrsea-Webhook-Timestamp"),
    mode: headers.get("X-Ovrsea-Webhook-Mode"),
    signature: headers.get("X-Ovrsea-Webhook-Signature"),
  });
  const timestampSeconds = Number(signed.timestamp);
  if (!Number.isSafeInteger(timestampSeconds) ||
      !Number.isSafeInteger(nowSeconds) ||
      Math.abs(nowSeconds - timestampSeconds) > 300) {
    throw new Error("Webhook timestamp outside receiver tolerance");
  }
  return signed;
};

const verifyWebhookSignature = ({ signed, rawBody, signingSecret }) => {
  if (!Buffer.isBuffer(rawBody) ||
      typeof signingSecret !== "string" || signingSecret.trim().length === 0) {
    throw new Error("Raw bytes and a signing secret are required");
  }
  const { timestamp, eventId, mode, signature } = signed;
  const expected = createHmac("sha256", Buffer.from(signingSecret, "utf8"))
    .update(`${timestamp}.${eventId}.${mode}.`, "utf8")
    .update(rawBody)
    .digest();
  const received = Buffer.from(signature.slice(3), "hex");
  if (received.length !== expected.length ||
      !timingSafeEqual(received, expected)) {
    throw new Error("Invalid webhook signature");
  }
};

const parseWebhookPayload = ({ rawBody, eventId }) => {
  const text = new TextDecoder("utf-8", { fatal: true }).decode(rawBody);
  const payload = payloadSchema.parse(JSON.parse(text));
  if (payload.eventId !== eventId) {
    throw new Error("Webhook event ID mismatch");
  }
  return payload;
};

export const verifyShipmentWebhook = ({
  headers,
  rawBody,
  signingSecret,
  nowSeconds = Math.floor(Date.now() / 1000),
}) => {
  const signed = parseWebhookHeaders({ headers, nowSeconds });
  verifyWebhookSignature({ signed, rawBody, signingSecret });
  const payload = parseWebhookPayload({ rawBody, eventId: signed.eventId });
  return { mode: signed.mode, payload };
};
```

Validation failures must stop processing. After verification, route `test` to
an acknowledgement without shipment updates, notifications or other business
effects. For `live`, complete the durable processing described below before
returning `2xx`.

## Acknowledge and handle duplicates

Store processed `eventId` values durably for this integration. Enforce uniqueness
and commit the event ID with its business changes in the same database
transaction. Concurrent requests must not apply the effect twice. An in-memory
set does not protect against restarts or multiple receiver instances. If the
effect calls another system, give that system a durable idempotency key based
on `eventId` before acknowledging completion.

Authenticate every attempt, including duplicates. An authenticated retry of an
already completed event should receive `2xx` again without repeating its effect.
Do not permanently reject an event ID just because it was seen before: the
previous acknowledgement may have been lost. A stale or unauthenticated replay
must not trigger processing. If processing fails, leave it retryable and return
a non-`2xx` response rather than recording the event as completed.

Any `2xx` acknowledges delivery; a small empty `204` response is sufficient.
Redirects, other status codes, timeouts and network failures do not acknowledge
it. DNS resolution and HTTP share a ten-second attempt budget, and the response
body is capped at 64 KiB.

Live deliveries have at most four attempts: the initial attempt and retries
eligible after 60, 600 and 3600 seconds from the preceding attempt's completion.
Polling and backlog can delay them further, and a failed signing step can
consume an attempt. Delivery is best effort: ordering, capture of every status
transition and exactly-once receipt are not guaranteed. Handle duplicates and
avoid assuming that arrival order is shipment progression order.

## Test your receiver

An owner or admin can send a test from **Shipment webhooks** after saving the
URL. Enter the shipment file reference shown in Hermes, for example
`BB-ABCD-3`. Hermes resolves it to the internal numeric shipment ID used by the
test request. The shipment must belong to the same exact client account;
visibility through a parent, child or shared account does not grant testing
permission. A REST shipment UUID is not a file reference. Choose a transport
shipment in a supported status; quotation-only states cannot be tested.

A test sends the selected shipment's current status to your configured URL with
the signed `X-Ovrsea-Webhook-Mode: test` header. Each test has a fresh event ID,
uses the same verification steps as a live request and has one synchronous
attempt with no automatic retry. It does not change the shipment or activate
the endpoint. Your receiver must acknowledge it without business effects.

The test result shows success or failure, attempt time, HTTP status when
available, duration and a sanitized error category. Check these alongside your
receiver's acknowledgement and durable processing records; the remote response
body is not shown.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.