For developers

Trajenda Order API

Push transport orders from your own system into Trajenda, and get the driver's status changes back as webhooks. One endpoint, one key, signed callbacks.

This page is the reference. The carrier creates the API key in their own Trajenda account under Beheer → Koppelingen and sends it to you; keys are shown once and can be revoked at any time.

Authentication

Every request carries the key as a bearer token. Keys look like tj_ followed by 48 hexadecimal characters, and they are bound to one carrier: the order lands in that company's planning and nowhere else.

Authorization: Bearer tj_1a2b3c…
Content-Type: application/json

Trajenda stores only a SHA-256 hash of the key, so a lost key cannot be recovered - it is replaced. Send it over HTTPS only, keep it out of browser code, and treat it like a password.

Create an order

POST https://trajenda.com/api/v1/orders
Authorization: Bearer tj_…
Content-Type: application/json

{
  "referentie": "ORD-2026-0001",
  "klantreferentie": "PO-8817",
  "opmerkingen": "Ring the bell at the gate",
  "stops": [
    {
      "naam": "DC Veghel",
      "adres": "Marshallweg 2, 5466 AH Veghel",
      "soort": "laden",
      "venster_van": "2026-09-22T08:00:00+02:00",
      "venster_tot": "2026-09-22T10:00:00+02:00",
      "referentie": "LOAD-91",
      "instructies": "Pallets are pre-stacked at dock 4"
    },
    {
      "naam": "Filiaal Tilburg",
      "adres": "Ringbaan Oost 100, 5013 CD Tilburg",
      "soort": "lossen",
      "gepland_op": "2026-09-22T13:30:00+02:00"
    }
  ]
}
HTTP/1.1 201 Created

{
  "trip_id": "0f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "referentie": "ORD-2026-0001",
  "bestond": false
}

The order becomes a trip in the planning straight away, ready for a planner to assign a driver and a vehicle. Field names are Dutch because they are the product's own names; they never change with the reader's language.

Fields

Order

FieldTypeRequiredNotes
referentiestringyesYour order number. It is the deduplication key - see below - and the number the driver and the paperwork show.
klantreferentiestringnoThe end customer's own number (a PO number, for instance). Shown on the printed trip sheet, and it becomes the reference on the invoice line - where it is present it wins over referentie.
opmerkingenstringnoFree text about the order as a whole. The planner sees it, and so does the driver on his phone - machine-translated into his own language. Not shown in the customer portal.
stopsarray of stop objectsyesAt least one, at most 50, in the order they must be driven.

Stop

FieldTypeRequiredNotes
naamstringyesName of the site, as the driver should recognise it.
adresstringnoOne line of free text. The driver sees it and navigates from it, and it goes on the paperwork. It is not matched against the carrier's own locations - putting a stop on the map is something the planner does in the app.
soort"laden" or "lossen"noLoading or unloading. Anything else is rejected; omitting it leaves the stop untyped.
gepland_opISO 8601 timestampnoA fixed appointment time. Between one year ago and two years ahead.
venster_vanISO 8601 timestampnoStart of a time window, when there is no fixed time. Same range as gepland_op.
venster_totISO 8601 timestampnoEnd of that window. Not before its start; with gepland_op, both window ends lie within 24 hours of it.
referentiestringnoReference for this stop specifically: a load number, a booking, a dock reservation.
instructiesstringnoInstructions the driver sees on his phone at this stop, in his own language where we can translate it.

Always send timestamps with a UTC offset (+02:00, or Z). A timestamp without one is read in the server's time zone, and that is how an appointment ends up two hours off.

Unknown fields are refused, not ignored. A typo like referentienr comes back as a 400 naming the field. A silently dropped field is a bug you only discover at the customer.

Length limits. Text fields are capped at 500 characters, except opmerkingen and instructies, which allow 2000 - both are free text, the rest are short identifiers. A field over its limit comes back as a 400 naming that field, with a fixed "reden": "te_lang". The request body as a whole is capped at 256 kB; above that the whole call is rejected before the JSON is even parsed - see the 413 below.

Responses and status codes

StatusMeaningBody
201Createdtrip_id, referentie, bestond: false - a new trip is in the planning.
200Already existedThe same trip_id, bestond: true. Nothing was changed.
400Bad requestNames the field: {"fout": "ongeldige_order", "veld": "stops[1].naam"}, or {"fout": "onbekend_veld", "veld": "…"} for a field we do not know. A time outside the rules adds a fixed "reden": te_vroeg, te_laat, eind_voor_begin or ver_van_gepland; a field over its length limit adds "reden": te_lang.
401Unauthorized{"fout": "geen_toegang"} - missing, unknown or revoked key, deliberately without telling you which.
409Conflict{"fout": "geen_planner_in_bedrijf"} - the carrier has no planner account to own the trip and has to fix that on their side. Or {"fout": "referentie_in_gebruik", "referentie": "…"} - a trip with this reference already exists in a different spelling (upper or lower case); see deduplication below.
413Payload too large{"fout": "order_te_groot"} - the request body is over 256 kB. Split the order or shorten its free-text fields; this is checked before the JSON is parsed, so no field name comes back.
429Too many requests{"fout": "te_veel_verzoeken"} - see the rate limit below.
503Unavailable{"fout": "tijdelijk_niet_beschikbaar"} - temporary; retry with the same referentie, deduplication makes that safe.
HTTP/1.1 400 Bad Request

{ "fout": "ongeldige_order", "veld": "stops[1].naam" }

Treat 400, 401 and 409 as "do not retry this payload": they are answers, not hiccups. 429 and 503 are worth retrying.

Retries and deduplication

referentie is unique per carrier. Send the same one twice and the second call returns 200 with bestond: true and the same trip_id - no second trip, no changes to the first. So a timeout, a restart or an over-eager retry queue cannot turn one order into two trucks.

That is also why there is no Idempotency-Key header: your order number already is one, and it is the number everyone involved can actually look up.

Two details worth knowing. A trip that was deleted or cancelled no longer blocks its reference: sending it again creates a fresh trip. And a second call never updates the first - to change a trip after creation, call the planner.

A reference can exist only once per carrier among its live trips, whoever created the trip - this API, the planner by hand or a spreadsheet import. Upper and lower case do not make a reference different: ord-1 and ORD-1 are the same one. Deduplication matches the exact spelling, so if you send a reference that differs from an existing trip only in case, you get 409 with referentie_in_gebruik instead of a second trip. Send the spelling you used the first time and you get the usual 200. Two calls with the same reference at the very same moment are safe: one creates the trip, the other gets bestond: true.

Rate limit

60 requests per minute per key, measured over the trailing minute, counted from the moment a request reaches the key check. Over that, every call returns 429 until the minute has moved on; nothing is queued. Bulk imports should pace themselves at roughly one order per second.

Every request is written to an audit log the carrier's management can read: path, status code and your reference. A 429 shows up there too, so "our orders are not coming through" is a question they can answer themselves.

Status webhooks

The carrier registers your endpoint under Koppelingen and picks which statuses to send. Trajenda POSTs JSON to it:

POST https://your-system.example/trajenda-hook
Content-Type: application/json
X-Trajenda-Signature-V1: t=1758521564,v1=5d41402ab…
X-Trajenda-Timestamp: 1758521564
X-Trajenda-Delivery: 7b2e4c1a-9d3f-4e8b-a6c5-1f0e2d3c4b5a
X-Trajenda-Signature: sha256=9f86d0818…
X-Trajenda-Gebeurtenis: onderweg
User-Agent: Trajenda-Webhooks/1.0 (+https://trajenda.com)

{
  "gebeurtenis": "onderweg",
  "trip_id": "0f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "referentie": "ORD-2026-0001",
  "tijdstip": "2026-09-22T06:12:44.183Z",
  "kenteken": "88-TRK-1",
  "trailer_kenteken": "OT-12-AB"
}

gebeurtenis is the trip status the driver moved to. The possible values are gepland, onderweg, aangekomen, wachten, laden, lossen and klaar; a new subscription listens to onderweg and klaar unless the carrier changes it. Your endpoint must be HTTPS.

kenteken is the registration of the truck and trailer_kenteken that of a separate trailer, as they stand on the trip at that moment - useful for gate registration at the warehouse. Both keys are always present; null means not known, and for trailer_kenteken usually that the truck runs without a separate trailer. The driver can change the trailer during the trip, so read it from the latest event.

Delivery

Verifying the signature

X-Trajenda-Signature-V1 looks like t=1758521564,v1=5d41…. t is the Unix time (seconds) of this delivery attempt; v1 is the hex HMAC-SHA256, using the subscription secret, of t, a full stop, and the raw request body: `${t}.${body}`. The secret starts with whsec_ and is visible to the carrier's management. Verify it before you trust a call: without that check your endpoint is a mailbox anyone can post into.

Then reject any t more than 300 seconds away from your own clock. Because the time is inside the signature, nobody can update it without the secret - so a copied request cannot be replayed later. A retry is signed again with a new t, so a slow retry is never rejected as old.

Hash the bytes you received, not a re-serialised object - key order and whitespace change the hash. And compare in constant time.

The older header X-Trajenda-Signature (sha256= plus the HMAC of the body alone) is still sent, so that existing receivers keep working. It is deprecated: it proves who sent a request, not when, so it does not stop replays. New integrations should check X-Trajenda-Signature-V1.

Node.js (Express)

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const SECRET = process.env.TRAJENDA_WEBHOOK_SECRET;
const TOLERANCE_S = 300;

// Raw body, not express.json(): the signature covers the exact bytes.
app.post("/trajenda-hook", express.raw({ type: "application/json" }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get("X-Trajenda-Signature-V1") ?? "").split(",").map((p) => p.split("=", 2)),
  );
  const t = parts.t ?? "";
  const expected = createHmac("sha256", SECRET)
    .update(t + "." + req.body.toString("utf8"))
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  if (!/^\d+$/.test(t) || a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).end();
  }
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_S) {
    return res.status(401).end();   // signed, but too old: a replay
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Answer first, work afterwards: you have 10 seconds.
  res.status(200).end();
  handle(req.get("X-Trajenda-Delivery"), event);  // skip ids you have seen
});

PHP

<?php
$secret = getenv('TRAJENDA_WEBHOOK_SECRET');
$body   = file_get_contents('php://input');   // raw, before json_decode
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_TRAJENDA_SIGNATURE_V1'] ?? ''), $parts);
$t        = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);

if (!ctype_digit($t) || !hash_equals($expected, $parts['v1'] ?? '')
    || abs(time() - (int) $t) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
http_response_code(200);   // answer within 10 seconds
// handle($_SERVER['HTTP_X_TRAJENDA_DELIVERY'], $event);

What this API does not do

Stated plainly, so you do not build around something that is not there:

Something missing that you need? Tell us what your system sends - the API grows with the integrations that actually exist.