---
title: Webhooks
description: Get a signed POST when a Calculate ends, verify it, and handle retries, so your system does not have to poll.
---

A webhook is a signed HTTPS POST that Cargo Marshal sends to your server when a Calculate in your organization ends. Use it to start the next step of your workflow as soon as a Load Plan is ready, without polling `GET /calculates/{id}`.

> **Before you begin**
>
> Webhooks need the Enterprise plan (the same `apiAccess` as the [persistence
> API](/overview/persistence-api)) and the Owner or Admin role, the
> roles that manage API keys. You also need a public HTTPS endpoint that can
> read the raw request body.

## Set up an endpoint

Open **Settings → Webhooks** to register an endpoint, choose the events it receives, and copy its signing secret. The secret starts with `whsec_`. It is shown once, when you create the endpoint and when you rotate it, so store it where your server can read it.

From the same page, owners and admins can disable and delete an endpoint, rotate the secret, send a test event, read the delivery log for the last 30 days, and redeliver an event.

## Events

An endpoint subscribes to any of three event types. Every Calculate in the organization ends in exactly one of them, whether the API or a Planner in the app started it.

| Event                 | Fires when                                                         |
| --------------------- | ------------------------------------------------------------------ |
| `calculate.completed` | The Calculate finished and stored its Load Plan.                   |
| `calculate.failed`    | The Calculate failed or ran out of time. `failureCode` says which. |
| `calculate.cancelled` | The Calculate was cancelled, for example by a Planner in the app.  |

A Calculate that times out arrives as `calculate.failed` with `failureCode: "timed_out"`. There is no separate timeout event.

An endpoint only receives events for Calculates that finished after it was enabled. A new endpoint gets no history.

Most events arrive within seconds of the Calculate ending. An event can occasionally wait for the next hourly sweep.

## The request

Cargo Marshal sends a `POST` with a JSON body. The body is small on purpose. It names the Calculate, Load Plan, and Project, and carries no plan data. Read the plan through the API.

```json
{
  "id": "evt_cm9x2k_completed",
  "type": "calculate.completed",
  "createdAt": "2026-10-11T09:00:00.000Z",
  "data": {
    "calculateId": "cm9x2k",
    "loadPlanId": "cm9w7p",
    "projectId": "cm9w1a",
    "revision": 7,
    "source": "api",
    "failureCode": null
  }
}
```

| Field              | Meaning                                                                                                                                                      |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`               | The event id. It is the same on every retry of the event.                                                                                                    |
| `type`             | One of the three event types above.                                                                                                                          |
| `createdAt`        | When the Calculate finished, as an RFC 3339 UTC timestamp.                                                                                                   |
| `data.calculateId` | The Calculate run. Pass it to `GET /calculates/{id}`.                                                                                                        |
| `data.loadPlanId`  | The Load Plan the run belongs to. Pass it to `GET /load-plans/{id}`.                                                                                         |
| `data.projectId`   | The Project that holds the Load Plan.                                                                                                                        |
| `data.revision`    | For `calculate.completed`, the revision the run stored. For the other events, the revision the run started from. Drop an event older than the plan you hold. |
| `data.source`      | `api` when an API key started the Calculate, `app` when a Planner did.                                                                                       |
| `data.failureCode` | `null` unless the event is `calculate.failed`. Then it is the run's failure code, or `timed_out`.                                                            |

The OpenAPI document at `/api/v1/spec.json` lists the same events under `webhooks`, so you can generate types for the body.

### Headers

Requests follow the [Standard Webhooks](https://www.standardwebhooks.com) specification.

| Header              | Value                                                               |
| ------------------- | ------------------------------------------------------------------- |
| `webhook-id`        | The delivery's message id. It is the same on every retry.           |
| `webhook-timestamp` | Unix time in seconds.                                               |
| `webhook-signature` | One or more space-separated signatures, each written `v1,<base64>`. |

## Verify the signature

Check the signature on every request before you trust it. Anyone who finds your URL can send a POST to it.

With a Standard Webhooks library, pass the raw body and the headers. Do not parse and re-serialize the JSON first, because the signature covers the exact bytes.

```ts
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.CARGO_MARSHAL_WEBHOOK_SECRET!); // whsec_…

export async function POST(request: Request) {
  const body = await request.text();
  let event;
  try {
    event = wh.verify(body, Object.fromEntries(request.headers)) as {
      id: string;
      type: string;
      data: { calculateId: string; loadPlanId: string; revision: number };
    };
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  // Record the event, then answer fast.
  return new Response(null, { status: 204 });
}
```

The library also rejects a timestamp that is far from the current time, which stops replays of an old request.

To verify without a library:

1. Build the signed content as `<webhook-id>.<webhook-timestamp>.<body>`, using the raw body.
2. Take the part of the secret after `whsec_` and base64-decode it. Those bytes are the HMAC key.
3. Compute HMAC-SHA256 of the signed content with that key, and base64-encode the result.
4. Compare it, in constant time, with each `v1,` signature in `webhook-signature`. The header can hold several, separated by spaces. Accept the request if any one matches.

### Rotate the secret

When you rotate a secret, the old one keeps signing for 24 hours. During that time each request carries two `v1,` signatures, one per secret. Deploy the new secret to your server within that window. Verification keeps working throughout, because a match on either signature is enough.

## Delivery guarantees

Delivery is at least once and not ordered. Build the receiver for both.

- **Dedupe on `webhook-id`.** A retry, or a resend after a crash, carries the same `webhook-id` and the same body. Record the ids you have handled and skip repeats.
- **Drop stale events.** Two events for the same Load Plan can arrive out of order. Compare `data.revision` with the revision you hold and ignore an older one.

### Responding

Answer with any `2xx` status within 10 seconds. Do slow work after you respond, for example by putting the event on your own queue.

These count as failures:

- A status outside `2xx`.
- A redirect. Cargo Marshal does not follow one, so register the final URL.
- No answer within 10 seconds.
- A connection or TLS error.

Cargo Marshal reads only the first 4 KiB of your response, and the delivery log keeps the first 1 KiB. Return a short body or none.

### Retries

A failed delivery retries on this schedule, with about 10 % jitter. Each wait counts from the failure of the previous attempt.

| Attempt | Wait before it |
| ------- | -------------- |
| 2       | 1 minute       |
| 3       | 5 minutes      |
| 4       | 30 minutes     |
| 5       | 2 hours        |
| 6       | 6 hours        |
| 7       | 12 hours       |
| 8       | 24 hours       |

Eight attempts is the limit. After the last one the delivery stays failed in the delivery log, and you can redeliver it from **Settings → Webhooks**.

### When an endpoint keeps failing

An endpoint that fails for three days is disabled, and Cargo Marshal stops sending to it. Any `2xx` response resets the count. The organization's owners and admins get an email when it happens. Fix the receiver, then enable the endpoint again in **Settings → Webhooks**.

An organization that leaves the Enterprise plan keeps its endpoints, but nothing is delivered until API access returns.

## Test and redeliver

Send a test event from the endpoint to check your receiver before a real Calculate runs. The test uses the same signing and delivery path as a real event, with its `type` set to `webhook.test`, so it exercises your signature check too. Answer it with a `2xx` like any other event.

To send an event again, for example after you fixed a bug, redeliver it from the delivery log. A redelivery keeps the same `webhook-id`, so your dedupe still applies.

The delivery log shows the last attempt and the attempt count for each delivery. It does not list every attempt.

## Network rules

Cargo Marshal checks the endpoint URL when you save it and again on every request:

- The URL must use `https` and must not contain a username or password.
- The port must be 443 or 1024 and above.
- The host must be a name. An IP address, such as `https://203.0.113.7/`, and `localhost` are rejected.
- The name must not resolve to a private, loopback, link-local, or cloud metadata address. One such address among the results is enough to refuse the request.

To receive webhooks while you develop, expose your local server through a tunnel that gives you a public HTTPS hostname.

## Recommended flow

Queue a Calculate through the API, wait for the webhook, and then fetch the Load Plan.

1. `POST /load-plans/{loadPlanId}/calculate` with an `Idempotency-Key`. Keep the run `id` from the `202` response, then return.
2. In your receiver, verify the signature and dedupe on `webhook-id`.
3. Match `data.calculateId` to the run you queued. An endpoint also hears about Calculates that Planners start in the app, so ignore the ones you did not queue, or use `data.source` to tell them apart.
4. On `calculate.completed`, call `GET /load-plans/{id}` with `data.loadPlanId` for the placements.
5. On `calculate.failed`, read `data.failureCode`. For `revision_conflict`, the Load Plan changed after you read it. Read it again and queue a new Calculate.

`GET /calculates/{id}` stays available when you want to check on a run yourself. The [persistence API](/overview/persistence-api) page covers every route.
