Webhooks
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}.
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.
{
"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 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.
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:
- Build the signed content as
<webhook-id>.<webhook-timestamp>.<body>, using the raw body. - Take the part of the secret after
whsec_and base64-decode it. Those bytes are the HMAC key. - Compute HMAC-SHA256 of the signed content with that key, and base64-encode the result.
- Compare it, in constant time, with each
v1,signature inwebhook-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 samewebhook-idand 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.revisionwith 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
httpsand 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/, andlocalhostare 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.
POST /load-plans/{loadPlanId}/calculatewith anIdempotency-Key. Keep the runidfrom the202response, then return.- In your receiver, verify the signature and dedupe on
webhook-id. - Match
data.calculateIdto 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 usedata.sourceto tell them apart. - On
calculate.completed, callGET /load-plans/{id}withdata.loadPlanIdfor the placements. - On
calculate.failed, readdata.failureCode. Forrevision_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.