Skip to content
Cargo Marshal
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  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.

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 page covers every route.

Was this page helpful?