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.
Shipment and versions
Section titled “Shipment and versions”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.
Lifecycle
Section titled “Lifecycle” issue pickup signature delivery signaturedraft ───────────▶ 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.
Signatures and trust levels
Section titled “Signatures and trust levels”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.
Documents
Section titled “Documents”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.
Integrity: the hash chain
Section titled “Integrity: the hash chain”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.
Recomputing it yourself
Section titled “Recomputing it yourself”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.
Tours and stops
Section titled “Tours and stops”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.
Providers and routing
Section titled “Providers and routing”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.
Test and live
Section titled “Test and live”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.