Skip to content

Concepts

One resource sits at the centre: the shipment. Everything Cargofollow does hangs off it — parties, goods, documents, signatures and events. This page explains the model the quickstart and the API reference stand on.

A shipment gets a shp_ id at POST /v1/shipments that never changes. Alongside it there is a version: a snapshot of every consignment note field, with its own ver_ id and its own document_hash.

Every substantive change creates a new version. The old one keeps existing and stays retrievable, because a signature belongs to the version somebody signed — not to “the shipment” in general. That is what lets you show, a year later, exactly what was signed.

Which fields may still change depends on the status:

Phase What is fixed What may still change
draft Nothing Everything
issued, in_transit Parties, addresses, goods, charges, transport_type, provider Driver, vehicle, time windows, contacts, instructions, metadata
delivered, completed, cancelled Everything Nothing

Trying to change a frozen field returns invalid_state; the full list lives in @freightapi/core/state.

issue pickup signature delivery signature
draft ───────────▶ issued ─────────────────────▶ in_transit ─────────────────▶ delivered
│ │ │ │
│ cancel │ cancel │ cancel │ (automatic)
▼ ▼ ▼ ▼
cancelled cancelled cancelled completed
From To Through
draft issued POST /v1/shipments/{id}/issue
issued in_transit The carrier’s signature at pickup
in_transit delivered The consignee’s signature at delivery
delivered completed Once every required signature is in
draft, issued, in_transit cancelled POST /v1/shipments/{id}/cancel

The state machine lives in @freightapi/core/state and is the only place transitions are defined. What is not in it cannot happen: a forbidden transition returns invalid_transition, including when you try to reach it sideways. Delivered is terminal — cancelled after delivered does not exist.

Every transition writes an event and therefore a link in the hash chain.

A signature in Cargofollow is four things at once: a role, a kind, a method and a trust level.

Dimension Values Meaning
Role consignor, carrier, consignee CMR boxes 22, 23 and 24: sender, carrier, consignee
Kind signature, seal A natural person signs, a legal person seals
Method drawn, click_otp, api, photo_of_paper How the signature came about
Trust level platform_auth, ades, qes How much it weighs in a dispute

The three trust levels, from light to heavy:

  • platform_auth — Cargofollow establishes who signed through the link, the token and optionally a one-time code by email or SMS. This is market practice in e-CMR and the default.
  • ades — an advanced electronic signature under eIDAS: uniquely linked to the signatory and to the data, so any later change becomes visible.
  • qes — a qualified signature, issued with a qualified certificate from a QTSP. Across the EU it is equivalent to a wet signature.

Every signature carries evidence: the timestamp, the document_hash of the version that was signed, the method, and depending on it a signature image, an OTP confirmation or a certificate chain. That evidence is what makes a signature usable in an argument — the image itself is the least interesting part.

Every file attached to a shipment is a document with a doc_ id, a kind, a MIME type and a sha256 of the bytes as they were stored.

Kind What it is
ecmr_pdf The consignment note as a PDF, re-rendered for each version
pod_pdf The proof of delivery, with the signatures on it
attachment Whatever you upload: a packing list, a customs document
signature_image The scribble behind a drawn signature
photo A photo attached to a reservation

Downloading goes through a short-lived signed URL, never a permanent link. An expired link returns 410; just ask for a new one. The sha256 on the document object is what you record if you want to prove later that you got the same file back.

Every shipment has an append-only chain of events. Each link carries its predecessor’s hash, so nothing can be inserted, removed or rewritten afterwards without everything after it failing to add up.

Per link:

hash = sha256_hex(prev_hash + canonical_json({ id, shipment_id, seq, type, occurred_at, actor, payload }))

With prev_hash = "" for seq = 1, and the previous link’s hash after that. Exactly these seven fields count — prev_hash, hash and anything the API wraps around them later do not. canonical_json is JSON without whitespace, object keys sorted recursively on UTF-16 code units (in the spirit of RFC 8785), hashed as UTF-8.

GET /v1/shipments/{id}/integrity recomputes the chain server-side:

{
"ok": true,
"events": 7,
"head_hash": "9f2c…",
"seq_gaps": [],
"verified_at": "2026-09-14T10:00:00.000Z"
}

Even with ok: false the answer is a 200 — a broken chain is a finding, not a fault in your request. broken_at then says at which seq it went wrong.

Do not take our word for it: fetch the links with GET /v1/shipments/{id}/events and recompute the chain with your own code.

import { createHash } from 'node:crypto'
/** JSON without whitespace, keys sorted recursively. */
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value !== null && typeof value === 'object') {
const entries = Object.keys(value)
.sort()
.filter((key) => value[key] !== undefined)
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
return `{${entries.join(',')}}`
}
return JSON.stringify(value)
}
export function verifyChain(events) {
let prev = ''
for (const event of events) {
const link = canonical({
id: event.id,
shipment_id: event.shipment_id,
seq: event.seq,
type: event.type,
occurred_at: event.occurred_at,
actor: event.actor,
payload: event.payload,
})
const hash = createHash('sha256').update(prev + link, 'utf8').digest('hex')
if (hash !== event.hash) return { ok: false, broken_at: event.seq }
prev = hash
}
return { ok: true, head_hash: prev }
}

Store the head_hash at the moment you read it. With it you can show later that nothing was added to or changed about the shipment after that moment, without having to believe us.

A driver rarely drives one shipment. POST /v1/tours groups several into one run, in the order it is driven.

The unit of a tour is a stop, not a shipment. One shipment gives a driver up to two stops that are hours and kilometres apart: the pickup, where the carrier signs for taking over, and the delivery, where the consignee signs for receipt. A list of shipments could not say that A and B are both collected before either is dropped off — and that is exactly what multi-drop is.

{
"reference": "Monday north",
"driver": { "name": "Marek Nowak", "phone": "+31612345678" },
"stops": [
{ "shipment_id": "shp_…A", "type": "pickup" },
{ "shipment_id": "shp_…B", "type": "pickup" },
{ "shipment_id": "shp_…A", "type": "delivery" }
]
}

Four things are worth knowing.

The order is yours. Cargofollow plans and optimises nothing; position is 1-based and follows the array you send. Reordering is a PATCH that replaces the whole list — a position means nothing without the other positions.

A shipment sits in at most one active tour. A second tour that claims it gets conflict naming the tour that already holds it. Move a tour to completed or cancelled and it lets its shipments go, free to be planned again.

done is derived, not stored. A stop is done once the signature it asks for exists: carrier at loading, consignee at delivery. That is why the progress of a tour cannot drift from the consignment notes it counts.

No new event type. A shipment joining or leaving a tour reports as shipment.updated with changed_paths: ["tour_id"]. If you already follow a shipment, you hear about it.

The driver sees the same run in the driver overview: stops in order, a navigation link per address, and per stop the button that opens the signing flow.

A shipment is executed by a provider: the party that legally issues the eCMR. Cargofollow can do that itself (native), but it can also route to an external recognised supplier.

Provider What it is
native Cargofollow issues it itself
mock Sandbox only: pretends, without an external call
pionira, transfollow, dashdoc, olf External eCMR platforms

You do not have to choose. Leave provider out and routing picks one based on the lane — and that is exactly what matters for Belgium, where the Benelux pilot only allows e-CMR through a supplier recognised by NIWO. See Country requirements.

Pick one yourself and it is respected, even where routing would have done something else. You then get the rule back as a finding in warnings, not as a block. Synchronising with an external provider can fail; that shows up as provider.sync_failed with a will_retry flag.

Your key prefix decides the mode, and nothing else:

Sandbox Live
Key sk_test_ sk_live_
Base URL https://api.eftisandbox.app https://api.cargofollow.com
External providers Never called Really called
Simulation Allowed Never
Billing No Yes

The two datasets never touch. An sk_test_ key sees no live shipments and the other way round; an id from one mode does not exist in the other. Calling a live route with an organisation that is not cleared for it returns live_not_enabled.

Validation, the state machine and the hash chain are identical in both modes. What you get working in the sandbox works the same live.