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) 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.
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.
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.
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.
{
"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.
Requests follow the Standard Webhooks 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>. |
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.
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:
<webhook-id>.<webhook-timestamp>.<body>, using the raw body.whsec_ and base64-decode it. Those bytes are the HMAC key.v1, signature in webhook-signature. The header can hold several, separated by spaces. Accept the request if any one matches.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 is at least once and not ordered. Build the receiver for both.
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.data.revision with the revision you hold and ignore an older one.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:
2xx.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.
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.
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.
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.
Cargo Marshal checks the endpoint URL when you save it and again on every request:
https and must not contain a username or password.https://203.0.113.7/, and localhost are rejected.To receive webhooks while you develop, expose your local server through a tunnel that gives you a public HTTPS hostname.
Queue a Calculate through the API, wait for the webhook, and then fetch the Load Plan.
POST /load-plans/{loadPlanId}/calculate with an Idempotency-Key. Keep the run id from the 202 response, then return.webhook-id.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.calculate.completed, call GET /load-plans/{id} with data.loadPlanId for the placements.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 page covers every route.