Vehicle Assignment API

Push a day’s plan into a Tenix depot, put vehicles on it, adjust it when reality changes, and read back what happened. Written for integrators connecting a route-planning or scheduling system to Tenix.

GraphQL over HTTPS POST · OAuth2 Bearer auth · SCHEDULE permission · two-step assignment

Throughout, replace the placeholders <YOUR_COMPANY_ID> and <YOUR_DEPOT_ID> with the values your Tenix contact provides.

What changed, and what to do about it

Two things on this page are different from earlier versions of it, and both make integration simpler.

Send day as a plain date. 2030-01-01 now works on every schedule read and write. Earlier guidance asked for a full UTC timestamp; those still work, so nothing you already built needs migrating.

The recommended path is now two calls, not one. Register the plan, then assign vehicles to it. It is what most current Tenix integrations use.

The one-step mutation has been renamed. assignJourneys is now registerAssignedJourneys, and it returns [String!]! rather than a Boolean, so an empty list means success, exactly as it does for the other schedule mutations. It is still fully supported; see §10.

1. Before you start

Your Tenix contact will:

  • Create and configure a depot for you. Configuration, including setting the depot’s timezone, is a Tenix-side step. A depot that merely exists but is not configured for scheduling will reject schedule calls, and getOrganization will list depots in that state. See §5.
  • Give you your companyId and depotId, and confirm your API credentials carry the SCHEDULE permission.

Credentials themselves are issued by Tenix. Email support@tenix.eu with the company, the access level you need, and whether you also need the MQTT API. There is no self-service registration.

You will do everything else from here using the API. Your vehicles should already exist in the platform, though note that the assignment calls do not check this for you (§7).

1a. Finding your own identifiers

You do not have to wait for someone to send you an id. getOrganization is the only operation in the API that takes no arguments, and it returns every company, division and depot your credential can see, which makes it the right first call in any integration.

bashwhoami.sh
curl -s -X POST "https://openapi.platform.tenix.eu/graphql" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"query":"query Org { organization { getOrganization { companies { id name active divisions { id name } depots { id name active } } } } }"}'

Match the depot you were told about by name, and take its id from the response rather than transcribing it by hand. Note that this lists every depot on the company, including ones not configured for scheduling, which is exactly what §5 checks for.

One operation reports a missing token differently

Called without a valid token, getOrganization returns INTERNAL_SERVER_ERROR with Cannot read properties of undefined (reading 'permissions') rather than UNAUTHENTICATED. Read that message as “no valid token”, not as a server fault. Every other operation reports it correctly.

2. Endpoint & authentication

ItemValue
GraphQL endpointhttps://openapi.platform.tenix.eu/graphql
Auth endpointhttps://openapi.platform.tenix.eu/auth
Method (GraphQL)POST, Content-Type: application/json
Permission neededSCHEDULE
Full schema referencetenix.tech/graphql

Authentication is OAuth2 password grant: exchange your username and password for a token, then send that token as a Bearer header on every call.

Get a token:

bashget-token.sh
curl -s -X POST "https://openapi.platform.tenix.eu/auth" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "username=$TODP_USER" \
  --data-urlencode "password=$TODP_PASS" \
  --data-urlencode 'scope=openapi' \
  --data-urlencode 'grant_type=password'

All four parameters must be present and non-blank, or the endpoint returns 400 with an empty body. The response contains an access_token. Send it on every GraphQL call:

http
Authorization: Bearer <access_token>

A token lasts one hour. expires_in comes back as 3600, and that is the authoritative value. There is no refresh token: renewal means re-posting the username and password. A long-running importer should re-mint at around 50 minutes rather than waiting for the first failure, which keeps authentication out of your request path. When a call returns UNAUTHENTICATED / “jwt expired”, re-authenticate and retry.

Two shell tips

Keep your credentials in environment variables rather than typing them into commands, so they do not end up in your shell history.

In bash, single-quote the entire -d '...' GraphQL payload. The ! in type names like RegisterScheduledJourneysInput! triggers history expansion (“event not found”) if left unquoted.

3. The model in brief

  • A block is a day’s consecutive work, identified by companyId + depotId + blockId + day. One vehicle normally covers a whole block, but it does not have to: a vehicle can be swapped out partway through (§11).
  • A scheduled journey is a single planned trip: start point and time → end point and time. It carries no vehicle.
  • An assigned journey is that same trip plus the vehicle doing it, and a type.
Company▶ Depot▶ Block (blockId)▶ ScheduledJourney+ vehicle▶ AssignedJourney

Planned and assigned are two different records. What the vehicle actually drove is a third, in the journey domain.

Keep those three apart when you build reports. The schedule domain answers “what was planned” and “who was meant to drive it”; the journey domain answers “what was driven”. They use different units; see §13.

4. Which path to use

There are two ways to get vehicles onto journeys. Both work and both are supported. They are not equivalent.

PathUse it when
registerScheduledJourneys
→ assignVehicleJourneys
The default, and the recommended one. The plan and the vehicle are separate facts, which is usually the truth. Plans are published in advance and vehicles change on the day. This is the path most current Tenix integrations use.
registerAssignedJourneys A one-shot import, where journeys already carry a vehicle and you have no planning layer to preserve. Documented in §10.

What each path can carry differs, and that, rather than any difference in reliability, is what should decide it for you.

CapabilityTwo-stepOne-step
energyConsumptionKwh per journeyyesyes
socLossPercentage per journeynoyes
Stop identifiers and coordinatesyes: StopPointInput takes id, latitude, longitudeno: JourneyPointInput is name and time only
allowOverassignmentyesno
Who sets registeredAtyou, per assignmentthe server’s clock
A separate plan you can read backyes: searchScheduledJourneysno; nothing is registered as scheduled
Writes to searchAssignmentChangesyesno

energyConsumptionKwh is worth setting on either path. It feeds charge planning: how much energy a vehicle needs before it starts a block, and the platform’s own estimate of the state-of-charge the block will cost. Without it that estimate cannot be computed, and charge-planning views that depend on it stay empty.

The reason to prefer two-step is not capability but shape: it keeps the plan and the vehicle as two separate facts, which is what they usually are. Reach for one-step when they genuinely arrive together.

There is also an assignment log, searchAssignmentChanges, which only the two-step path writes to. Read its entry in the schema reference before building on it: it is a log of received requests, successful or not, built for one mediator integration that delivers assignment events before the journeys they refer to. It is not a general audit trail, and you are not expected to use it. To answer “which vehicle covered this block?”, use searchAssignedJourneys.

5. Confirm your depot is ready

A depot has to be configured for scheduling before any of this works, and that is a Tenix-side step. A depot can exist, and appear in getOrganization, without being configured.

The reliable check is a write that reads back. Register one throwaway journey on a far-future day, read it, then remove it. If it comes back, the depot is ready and your credential can reach it. §15 does exactly this, and eight other things, in one script.

The message to watch for

Depot is not configured (<depotId>) means the depot has not been set up for scheduling. Contact Tenix; nothing you write will be stored until it is.

Do not read an empty result as a failed depot

Every schedule write signals success with an empty list, and every read of a block you have not written to returns an empty list too. The two look identical. So an empty read on its own tells you nothing about the depot: it is only meaningful next to a write you just made.

Make the write-and-read-back check part of your integration’s start-up rather than something you did once during onboarding. If a write returns [] and the read that follows it comes back empty, check the depot before you start debugging your payload.

6. Step 1: Register the plan

Journeys first, without vehicles. One call per block.

graphql
mutation Plan($input: RegisterScheduledJourneysInput!) {
  schedule {
    registerScheduledJourneys(input: $input)
  }
}
jsonvariables
{
  "input": {
    "companyId": "<YOUR_COMPANY_ID>",
    "depotId": "<YOUR_DEPOT_ID>",
    "blockId": "BLOCK-001",
    "day": "2030-01-01",
    "journeys": [
      {
        "externalId": "my-system-8891",
        "line": "1",
        "distance": 12000,
        "energyConsumptionKwh": 15.5,
        "startPoint": { "name": "Central Station", "time": "2030-01-01T06:00:00.000Z", "id": "ST-001", "latitude": 59.9139, "longitude": 10.7522 },
        "endPoint":   { "name": "Harbour Terminal", "time": "2030-01-01T06:40:00.000Z", "id": "ST-042" }
      },
      {
        "externalId": "my-system-8892",
        "line": "1",
        "distance": 12000,
        "energyConsumptionKwh": 15.1,
        "startPoint": { "name": "Harbour Terminal", "time": "2030-01-01T07:00:00.000Z", "id": "ST-042" },
        "endPoint":   { "name": "Central Station", "time": "2030-01-01T07:45:00.000Z", "id": "ST-001" }
      }
    ]
  }
}

As a single curl call:

bashplan.sh
curl -s -X POST "https://openapi.platform.tenix.eu/graphql" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "query": "mutation($input: RegisterScheduledJourneysInput!){ schedule { registerScheduledJourneys(input:$input) } }",
    "variables": { "input": { "companyId":"<YOUR_COMPANY_ID>", "depotId":"<YOUR_DEPOT_ID>", "blockId":"BLOCK-001", "day":"2030-01-01", "journeys":[ { "externalId":"my-system-8891", "line":"1", "distance":12000, "energyConsumptionKwh":15.5, "startPoint":{"name":"Central Station","time":"2030-01-01T06:00:00.000Z","id":"ST-001","latitude":59.9139,"longitude":10.7522}, "endPoint":{"name":"Harbour Terminal","time":"2030-01-01T06:40:00.000Z","id":"ST-042"} } ] } }
  }'
An empty list means success

{"data":{"schedule":{"registerScheduledJourneys":[]}}}. The write succeeded.

Despite the [String!]! return type, this is not a list of identifiers. No schedule mutation returns one. That is deliberate: the schedule data is yours, not Tenix’s, so the API does not oblige you to store Tenix identifiers for your own records. The list is a validation-error channel: empty is a clean write, anything in it is a failure message. Key your records on your own externalId.

This convention is the same for all three write mutations: registerScheduledJourneys, assignVehicleJourneys and registerAssignedJourneys. A failure arrives as HTTP 200 with messages in that list and no GraphQL errors array, so the test is length === 0, never a truthiness check. Reads behave the other way round: a bad filter is a real GraphQL error with an empty data.

Four habits that pay off later

  • Always set externalId. It is the only handle findAssignedJourney accepts, the only stable link back to your own planning system, and (see below) part of the record’s identity. The schema marks it optional; treat it as required.
  • Set energyConsumptionKwh if you have an estimate. It is what makes this path worth using (§4).
  • Send stop-point coordinates. latitude, longitude and id are all optional on StopPointInput; only name and time are required. Send them anyway. They are stored and other parts of the platform use them. Note the read side returns only name and time, so you cannot recover them from this API afterwards.
  • Re-registering is safe, and that cuts both ways. A journey’s identity is derived from the depot, then externalId (falling back to line, then blockId), then its start and end times. Send the same journey twice and the second write replaces the first, so retries are harmless. But day is not part of that identity: registering the same journey for a different day replaces the earlier record rather than adding one. If your timetable repeats a line at identical clock times across days, vary externalId per day.
distance is in metres

12000 above is 12 km. Watch the asymmetry when you compare against reality later: the actual distance driven comes back from the journey domain in kilometres, so a plan-versus-actual comparison needs a factor of 1000. This is the easiest way to produce a confidently wrong report from this API.

7. Step 2: Assign vehicles to the plan

An assignment is a vehicle plus an interval, not a vehicle plus a journey. Every journey already registered in that block whose start and end fall inside from–to becomes that vehicle’s.

graphql
mutation Assign($input: AssignVehicleJourneysInput!) {
  schedule {
    assignVehicleJourneys(input: $input)
  }
}
jsonvariables
{
  "input": {
    "companyId": "<YOUR_COMPANY_ID>",
    "depotId": "<YOUR_DEPOT_ID>",
    "blockId": "BLOCK-001",
    "day": "2030-01-01",
    "receivedAt": "2029-12-31T04:02:11.000Z",
    "assignments": [
      {
        "vehicleNumber": "101",
        "from": "2030-01-01T06:00:00.000Z",
        "to":   "2030-01-01T07:45:00.000Z",
        "registeredAt": "2029-12-31T04:00:00.000Z"
      }
    ]
  }
}

[] again means success. The window must contain a journey, inclusively, for that journey to be picked up. A from/to that clips either end matches nothing.

["There are no assignments to register"]

This is the one error you are likely to meet, and it is not about permissions or a malformed request. It means the server matched your vehicle windows against the block’s registered journeys and the result was empty. There are exactly two causes:

  1. The block has no journeys registered for that depot and day. Verify with searchScheduledJourneys using the same day value before assigning.
  2. Your window does not fully contain any journey. Widen from/to.

The two timestamps

Both are required and both are yours to set. They mean different things, and setting them honestly is what lets anyone later distinguish “the plan changed at 05:00” from “we found out at 07:00”.

FieldMeaning
registeredAt (per assignment)When your system decided this assignment
receivedAt (per call)When it reached your integration boundary

What gets validated, in order

CheckMessage
depot configured for schedulingDepot is not configured (<depotId>)
blockId non-blankBlock ID must not be empty
day not before depot-local todayAssignment day must not be earlier than today
journey ends no earlier than todayEnd time must not be earlier than today (…)
end after startStart time must be before end time (…)
a vehicle identifier presentVehicle identifier must be provided (…)
no overlap for that vehicleVehicle is already assigned within provided time scope (…)
The batch is all-or-nothing, and the error list may be incomplete

If any journey in the batch fails validation, nothing is stored. The returned list is de-duplicated, so two identical failures appear once; each message carries its own description of the offending journey (block, vehicle, line, start, end), which is the only way to tell them apart.

Validation also runs in two passes: times and vehicle presence first, overlap second. If anything fails the first pass the second never runs, so a batch with both kinds of problem reports only the first kind and you need another round trip to see the rest. Fix what you are told, then re-send, rather than assuming one response lists everything.

Three things the validation does not do

  1. It does not check that the vehicle exists, only that some identifier was supplied. A vehicleNumber that matches no vehicle is accepted and stored, so a typo in a fleet number becomes an assignment nobody notices. Validate before you send; see §7a.
  2. Overlap is strict. Journeys that merely touch, one ending exactly as the next begins, do not collide. Re-sending an assignment identical to a stored one is treated as the same assignment rather than a conflict, so retries are safe.
  3. In-batch overlap is caught. Two overlapping journeys for one vehicle inside a single call are rejected outright, and the message names both. A client-side guard is no longer required, though grouping your batch by vehicle and checking for overlapping spans before sending still gives you a better message and saves a round trip.

allowOverassignment: true skips the overlap check entirely, permitting several vehicles on the same interval of one block. Leave it unset unless you specifically need it. Accidental double assignment is otherwise caught for you.

7a. Checking a fleet number before you send it

Because nothing downstream will catch a bad one, validate against your own fleet list where you can. If your credential also carries the VEHICLE permission, searchVehicles will do it, but read the warning below before you rely on it.

graphqlsearchVehicles
query Fleet($f: SearchVehiclesFilter!) {
  vehicle {
    searchVehicles(filter: $f) {
      id internalNumber divisionId fuelType deleted
    }
  }
}
jsonvariables
{ "f": {
  "companyId": "<YOUR_COMPANY_ID>",
  "internalNumber": "^101$",
  "deleted": false
} }
The string filters are regular expressions, and they are not escaped

internalNumber, vin and bmsId are matched as case-insensitive substring patterns, so this is a search box rather than a key lookup. internalNumber: "101" also matches 1010 and 2101; "1.7" matches 117, because . is a wildcard.

A validation step written the obvious way will therefore pass on a fleet number that does not exist. Anchor the pattern, as in ^101$ above, to get an exact match server-side, and escape any value that came from user input. Or re-check on your side after the call: jq --arg n 101 '[.[] | select(.internalNumber == $n)]'.

Two more things about the result. deleted is tri-state on read: null on some live vehicles, false on others, true on retired ones. Test for truthiness rather than === false. And a retired vehicle is never erased; it keeps its history and stays queryable, so a fleet number you recognise may belong to a vehicle that is no longer in service.

8. Step 3: Verify it landed

Both schedule mutations signal success with an empty list, so a read is the source of truth. One query covers the whole block.

graphqlread back the block
query Day($cid: String!, $did: String!, $day: Date!, $block: String!) {
  schedule {
    searchScheduledBlock(filter: { companyId: $cid, depotIds: [$did], day: $day, blockId: $block }) {
      depotId blockId
    }
    searchScheduledJourneys(filter: { companyId: $cid, depotId: $did, day: $day, blockId: $block }) {
      blockId day line distance externalId energyConsumptionKwh
      startPoint { name time }
      endPoint { name time }
    }
    searchAssignedJourneys(filter: { companyId: $cid, depotId: $did, day: $day, blockId: $block }) {
      blockId day vehicleNumber type line distance registeredAt externalId
      startPoint { name time }
      endPoint { name time }
    }
  }
}
jsonvariables
{
  "cid": "<YOUR_COMPANY_ID>",
  "did": "<YOUR_DEPOT_ID>",
  "day": "2030-01-01",
  "block": "BLOCK-001"
}

One day variable serves the whole query. All three filters type it as Date!. Note that searchScheduledBlock takes depotIds as a list where its siblings take a single depotId. That difference remains.

Filter rules the schema does not express

OperationRule
searchAssignedJourneys Needs exactly one of blockId / vehicleNumber. With neither: BlockId or VehicleNumber must be provided. With both: Only one of filters: BlockId, VehicleNumber can be provided. So there is no single call that lists a whole depot’s day. Loop over your blocks, or over your vehicle numbers.
searchScheduledBlock Needs blockId or externalId, plus a non-empty depotIds. Blocks therefore cannot be enumerated, so keep your own list of the block IDs you write. It returns only depotId and blockId, so its real use is existence checking, not discovery.

8a. Verifying one journey by your own externalId

When you only need to confirm one specific journey, findAssignedJourney is a tighter round-trip: it looks the journey up by the externalId you set on it and returns a single AssignedJourney, or null.

graphqlfindAssignedJourney
query Find($f: FindAssignedJourneyFilter!) {
  schedule {
    findAssignedJourney(filter: $f) {
      blockId day vehicleNumber type line externalId
      startPoint { name time }
      endPoint { name time }
    }
  }
}
jsonvariables
{ "f": {
  "companyId": "<YOUR_COMPANY_ID>",
  "depotId": "<YOUR_DEPOT_ID>",
  "day": "2030-01-01",
  "externalId": "my-system-8891"
} }

All four filter fields are required. This is the payoff for setting externalId in §6: confirm each journey with a single find rather than paging a list.

Not-found behaviour

An externalId that matches nothing returns findAssignedJourney: null cleanly, with no GraphQL error, so a client can treat null as “not found” directly. For a journey you know you registered, null means it was never assigned, not that it does not exist. Check searchScheduledJourneys to tell the two apart.

8b. externalId uniqueness is not enforced

The platform does not enforce uniqueness of externalId. Two journeys in the same block can carry the same one; both are accepted and both are stored. findAssignedJourney on a shared externalId returns only one of them (the earlier journey in the block) silently, with no error and no warning.

So:

  • Treat findAssignedJourney(externalId) as “find a match”, not “find the match”. It is safe as a verification key only if uniqueness is guaranteed upstream.
  • Guarantee uniqueness in your own system. externalId is your value, so compose it to be unique per journey: block plus trip index, say, rather than line or vehicle alone. Remember to vary it per day as well (§6).
  • When you must see every journey on a block, use searchAssignedJourneys by blockId. A duplicate externalId hides journeys from find but never from search.

Three things about the rows you get back

  • The day reads back as depot-local midnight, not what you sent. For a Norwegian depot, 2030-01-01 comes back as 2029-12-31T23:00:00Z in winter and …T22:00:00Z in summer. See §13.
  • You will get rows for the previous operating day. searchAssignedJourneys deliberately looks at two operating days and keeps the rows that end after the requested day’s start, so a journey running across local midnight appears in the query for the day it was registered under and in the next day’s. Measured: a journey registered under 2030-01-01 and running 22:30Z to 00:30Z came back from both a 2030-01-01 and a 2030-01-02 query, reporting day as its own operating day both times. Read the day field rather than assuming it matches your filter. This is intended behaviour, not a fault.
  • You get back the journeys you wrote, and nothing else. The JourneyType enum includes DEPOT_PARKING, but this API does not synthesise those rows. A block whose vehicle sits at a depot-named stop between journeys still returns exactly the journeys you registered. So no type filter is needed before aggregating, and a row count that does not match what you wrote means something else is wrong.

Returned timestamps also drop milliseconds: you send 06:00:00.000Z and read back 06:00:00Z. Compare parsed instants, never timestamp strings.

9. Journey type

Every assigned journey carries a type, returned as AssignedJourney.type. The JourneyType enum defines three values:

SERVICE
The vehicle is in service on a route.
DEAD_RUN
The vehicle is driving between the depot and the start or end of a route (a deadhead run, no passengers), in either direction.
DEPOT_PARKING
Reserved for a vehicle standing at the depot between journeys. Declared in the enum, but not returned by this API today; see §8. Never something you write.
type is derived from line

You do not supply it, and neither journey input has a type field. The platform derives it: a journey with a line is SERVICE, one without is DEAD_RUN. So omit line deliberately to record a dead run, and always set it on a service journey.

10. The one-step alternative

One call carries the journeys and the vehicle on each of them. Use it when the plan and the vehicle genuinely arrive together and you have no planning layer to preserve; prefer the two-step path otherwise.

Renamed

This operation was previously called assignJourneys and returned a nullable Boolean. It is now registerAssignedJourneys, takes RegisterAssignedJourneysInput!, and returns [String!]!, the same validation-error channel as every other schedule mutation. If you are migrating, the input fields are unchanged; only the operation name, the input type name and the return handling differ.

graphql
mutation RegisterAssigned($input: RegisterAssignedJourneysInput!) {
  schedule {
    registerAssignedJourneys(input: $input)
  }
}
jsonvariables
{
  "input": {
    "companyId": "<YOUR_COMPANY_ID>",
    "depotId": "<YOUR_DEPOT_ID>",
    "blockId": "BLOCK-001",
    "day": "2030-01-01",
    "journeys": [
      {
        "vehicleNumber": "101",
        "line": "1",
        "distance": 12000,
        "externalId": "my-system-8891",
        "energyConsumptionKwh": 15.5,
        "socLossPercentage": 8.5,
        "startPoint": { "name": "Central Station", "time": "2030-01-01T06:00:00.000Z" },
        "endPoint":   { "name": "Harbour Terminal", "time": "2030-01-01T06:40:00.000Z" }
      }
    ]
  }
}
An empty list means success

{"data":{"schedule":{"registerAssignedJourneys":[]}}}. The write landed.

A failure comes back as a message in that same list, with HTTP 200 and no GraphQL errors array. Each message names the offending journey, so a rejected batch is diagnosable from the response alone:

["Start time must be before end time (Block: BLOCK-001, Vehicle: 101, Line: 1, Start: …, End: …)"]

So the check is length === 0, not a truthiness test. And because the batch is all-or-nothing, a non-empty list means nothing was stored.

What this path does and does not carry

  • JourneyInput takes vehicleNumber, distance, line, startPoint, endPoint, externalId, energyConsumptionKwh and socLossPercentage. socLossPercentage is available only here: on the two-step path the platform derives it rather than accepting it.
  • JourneyPointInput is { name, time } only: no stop identifiers, no coordinates. Use §6 if you need those.
  • registeredAt is the server’s clock, not a value you supply. Every journey in one call shares the same timestamp to the millisecond.
  • allowOverassignment is unavailable here.
  • searchScheduledJourneys will not show these journeys, because they were never registered as scheduled. That is correct, not a fault.
  • searchAssignmentChanges stays empty for anything written this way, by design.

Everything else on this page applies unchanged: the same day handling, the same validation, the same reads.

10a. externalId uniqueness is not enforced

See §8b; it applies to both paths.

11. Handling a change

Reality intervenes: a vehicle breaks down mid-block and another takes over from 07:00. Remove the old assignment for the remaining interval, then assign the replacement over the same interval with assignVehicleJourneys.

graphql
mutation Unassign($input: RemoveVehicleJourneysInput!) {
  schedule {
    removeVehicleJourneys(input: $input) { removedJourneys errors }
  }
}
jsonvariables
{ "input": {
  "companyId": "<YOUR_COMPANY_ID>",
  "depotId": "<YOUR_DEPOT_ID>",
  "blockId": "BLOCK-001",
  "day": "2030-01-01",
  "from": "2030-01-01T07:00:00.000Z",
  "to":   "2030-01-01T07:45:00.000Z",
  "vehicleNumber": "101",
  "registeredAt": "2030-01-01T06:47:00.000Z",
  "receivedAt":   "2030-01-01T06:48:12.000Z"
} }
Check both fields of the result

removedJourneys can be non-zero while errors is non-empty. That is a partial removal, and it needs looking at rather than logging as a success.

And note that omitting vehicleNumber clears every vehicle’s assignment in the window. That is occasionally what you want, and never what you want by accident.

12. Removing journeys and test data

Two operations, depending on what you are removing.

Cancel planned journeys by time window

graphql
mutation Cancel($input: RemoveScheduledJourneysInput!) {
  schedule {
    removeScheduledJourneys(input: $input) { removedJourneys errors }
  }
}
jsonvariables
{ "input": {
  "companyId": "<YOUR_COMPANY_ID>",
  "depotId": "<YOUR_DEPOT_ID>",
  "blockId": "BLOCK-001",
  "day": "2030-01-01",
  "from": "2030-01-01T06:00:00.000Z",
  "to":   "2030-01-01T07:45:00.000Z"
} }

Removal here is by time window, not by id, so the window is your safety boundary. Derive it from the journeys you actually intend to remove: read them first, take the earliest start and the latest end, rather than passing the whole day.

Remove one assigned journey by exact times

graphql
mutation Remove($input: RemoveAssignedJourneyInput!) {
  schedule {
    removeAssignedJourney(input: $input)
  }
}
jsonvariables
{ "input": {
  "companyId": "<YOUR_COMPANY_ID>",
  "depotId": "<YOUR_DEPOT_ID>",
  "blockId": "BLOCK-001",
  "day": "2030-01-01",
  "vehicleNumber": "101",
  "startPointTime": "2030-01-01T06:00:00.000Z",
  "endPointTime":   "2030-01-01T06:40:00.000Z"
} }

This matches on exact start and end times, so it removes one journey per call. It returns a nullable Boolean, the one schedule mutation that still does, so re-run the §8 read afterwards rather than trusting the return value. An empty list confirms it is gone.

To clear a whole block instead, removeVehicleJourneys (§11) works on journeys written by either path and reports an accurate count.

Records are retired, not erased

Scheduled journeys and assignments carry a time-to-live that expires at the end of the calendar year two years after they were written, and the clock starts at write time rather than at the day being described. A plan registered today for a date more than two years out will therefore be removed before it happens. If you publish timetables that far ahead, re-register them closer to the day.

13. Rules & gotchas

  1. Send day as a plain YYYY-MM-DD. It is unambiguous, it is the shortest thing that works, and it works across every schedule read and write. Full timestamps still work if they carry Z or an explicit offset.
  2. Zero-pad the date, and always include Z or an offset on a timestamp. The gateway expands day with JavaScript’s new Date(), which is lenient in ways that matter:
    You sendParsed asSafe?
    2030-01-01UTC midnight✅ deterministic
    2030-01-01T00:00:00ZUTC midnight✅ deterministic
    2030-01-01T06:00:00+02:0004:00 UTC✅ offsets are honoured
    2030-01-01T00:00:00the gateway server’s local midnight❌ environment-dependent
    2030-1-1the gateway’s local midnight, not the same as the padded form❌
  3. Validate dates client-side. A malformed day does not come back as a validation message. It surfaces as INTERNAL_SERVER_ERROR with Cannot read properties of undefined (reading 'status'). The message will not help you diagnose it, so check the format before you send.
  4. You cannot backdate. Both the block’s day and each journey’s end time are compared against depot-local today.
  5. What you read back is depot-local midnight. The depot has its own timezone, and day is normalised to midnight in it, stored that way and returned that way, rendered in UTC. This is DST-aware: the offset tracks the depot’s actual offset on that date, so do not hardcode it and do not describe it as “the previous day at 22:00”. Only day shifts; journey startPoint.time and endPoint.time read back exactly as sent.
    Sent (day)Read backDepot offset
    2030-01-012029-12-31T23:00:00ZUTC+1 (CET, winter)
    2026-08-202026-08-19T22:00:00ZUTC+2 (CEST, summer)
    Compare parsed instants, or compare against the value the API returned. Never the strings.
  6. A block that spans local midnight belongs to the operating day you registered it under. Journey times are absolute instants; only day is a bucket, and nothing splits a block at midnight. See the carryover note in §8.
  7. Journey times are UTC. Send startPoint.time / endPoint.time as full timestamps with Z.
  8. Planned distance is metres; actual distance is kilometres. ScheduledJourney.distance and AssignedJourney.distance are Int metres. The actual distance a vehicle drove, in the journey domain, is a Float in kilometres. Any plan-versus-actual comparison needs a factor of 1000.
  9. Vehicle existence is not validated. A vehicleNumber matching no vehicle is accepted and stored. Check your fleet numbers before you send (§7).
  10. Vary externalId per day if your timetable repeats a line at identical clock times, because day is not part of a journey’s identity (§6).

14. Troubleshooting

MessageMeaning / fix
Depot is not configured (…)The depot is not set up for scheduling. Contact Tenix (§5).
["There are no assignments to register"]Your vehicle windows matched no registered journey. Either the block has none for that depot and day, or your from/to does not fully contain one (§7).
Assignment day must not be earlier than todayUse a day of today or later. You cannot backdate.
End time must not be earlier than today (…)Same rule, applied to each journey’s end time.
Start time must be before end time (…)The journey’s points are the wrong way round. On the write mutations this arrives as an entry in the returned list, not as a GraphQL error.
Vehicle identifier must be provided (…)vehicleNumber is missing or blank on an assignment.
Vehicle is already assigned within provided time scope (…)That vehicle overlaps an existing or in-batch assignment. Adjust the window, or set allowOverassignment if the overlap is intended.
Block ID must not be emptyblockId is blank.
BlockId or VehicleNumber must be providedAdd one, but not both, to a searchAssignedJourneys filter.
Only one of filters: BlockId, VehicleNumber can be providedYou sent both. Pick one and filter the rest client-side.
BlockId or ExternalId must be providedA searchScheduledBlock filter needs one of them; blocks cannot be enumerated.
At least one depotId must be providedsearchScheduledBlock takes depotIds as a non-empty list.
INTERNAL_SERVER_ERROR / Cannot read properties of undefined (reading 'status')A malformed day. Validate the format client-side (§13).
jwt expired / UNAUTHENTICATEDToken expired; re-authenticate (§2). Tokens last one hour.
User does not have permissions set for companyThe companyId is blank or is not one your credential holds. Note the message ends with the company it was given. If nothing follows it, you sent an empty string.
Cannot query field 'assignJourneys' on type 'ScheduleMutations'That mutation was renamed. Use registerAssignedJourneys (§10).
Not yet implementedThe operation exists in the schema but is not available yet. Do not rely on it.
event not found (in bash)The ! in a type name triggered shell history expansion. Single-quote the whole -d '...' payload.
Two things about errors on this API

An empty write result is success, not silence. registerScheduledJourneys, assignVehicleJourneys and registerAssignedJourneys all return [] when the write lands, and a list of messages when it does not. If you get [] but a read comes back empty, the write is not what failed. Check the depot configuration and that you are reading with the same day and blockId you wrote.

Match on errorClass, not code. Filter errors carry extensions.errorClass: "ScheduleRequestInvalidError", but extensions.code comes back as the string "undefined" rather than a useful value. Branch on the class or on the message text.

15. Prove it end to end

Before you map your own scheduling output onto these fields, prove the path works. This script walks the whole cycle against a depot of your choosing: get a token, resolve your own company and depot ids by depot name, register a plan, read it back, assign a vehicle, verify, swap a vehicle mid-block, verify the split, then remove everything it created.

It asks for your username, password and depot name when it starts. Nothing is echoed and nothing reaches your shell history. It needs bash, curl and python3, installs nothing, writes to a block id unique to the run, and uses a far-future day so it cannot collide with real schedule data.

Save it to a file. Do not paste it into a terminal.

The script contains quoted JSON that an interactive shell will mangle if you paste it line by line. Save it as prove-it.sh, then run bash prove-it.sh.

If you have no easy way to save a file, paste it inside a quoted heredoc instead, which bash reads without interpreting: type cat > prove-it.sh <<'PROVEIT', paste the script, then a line containing only PROVEIT.

bashprove-it.sh
#!/usr/bin/env bash
# prove-it.sh - walk the whole Schedule API cycle against your own depot.
#
# SAVE THIS TO A FILE AND RUN IT. Do not paste it into a terminal: the quoting
# will not survive, and you will get a screen of failures that mean nothing.
#
#   1. save as prove-it.sh
#   2. bash prove-it.sh
#
# It asks for your username, password and depot name. Nothing is echoed and
# nothing is written to your shell history. To run it unattended instead, set
# TODP_USER, TODP_PASS and DEPOT_NAME beforehand and it will not ask.
#
# Needs bash, curl and python3. Installs nothing. Leaves nothing behind.
set -uo pipefail

die() { printf '\n  %s\n\n' "$1" >&2; exit 2; }
command -v curl    >/dev/null || die "curl is not installed."
command -v python3 >/dev/null || die "python3 is not installed."

# Ask for anything that was not supplied. Set the variables beforehand to run
# unattended; in a pipeline, a missing one is an error rather than a prompt.
ask() {  # ask VAR "prompt" [silent]
  local cur; cur=$(eval "printf '%s' \"\${$1:-}\"")
  [ -n "$cur" ] && return 0
  [ -t 0 ] || die "$1 is not set. Export it, or run this from a terminal so it can ask."
  if [ "${3:-}" = "silent" ]; then read -rs -p "  $2: " "$1" </dev/tty; echo
  else read -r -p "  $2: " "$1" </dev/tty; fi
  [ -n "$(eval "printf '%s' \"\${$1}\"")" ] || die "$1 cannot be blank."
}
ask TODP_USER  "API username"
ask TODP_PASS  "password (not shown)" silent
ask DEPOT_NAME "depot name, or part of it"

AUTH=https://openapi.platform.tenix.eu/auth
EP=https://openapi.platform.tenix.eu/graphql
DAY="${DAY:-2030-01-01}"
BLOCK="PROVEIT-$$"
VEH_A="${VEH_A:-PROVEIT-A}"
VEH_B="${VEH_B:-PROVEIT-B}"
NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
PASS=0; FAIL=0

P='import sys,json,functools as f'
ok()  { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n          %s\n' "$1" "$2"; }
gql() { curl -s -X POST "$EP" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$1"; }
# val '<json>' a.b.c  -> that node as JSON, or null
val() { printf '%s' "$1" | python3 -c "$P;d=json.load(sys.stdin);print(json.dumps(f.reduce(lambda a,k:a.get(k) if isinstance(a,dict) else None,sys.argv[1].split('.'),d)))" "$2" 2>/dev/null || echo null; }
# len '<json>' a.b.c  -> length of that list, or 0
len() { printf '%s' "$1" | python3 -c "$P;d=json.load(sys.stdin);v=f.reduce(lambda a,k:a.get(k) if isinstance(a,dict) else None,sys.argv[1].split('.'),d);print(len(v) if isinstance(v,list) else 0)" "$2" 2>/dev/null || echo 0; }
# why '<json>' -> the first error message, shortened, with a hint for the common ones
why() { printf '%s' "$1" | python3 -c "$P;d=json.load(sys.stdin);e=(d.get('errors') or [{}])[0].get('message','no error message');print(e.strip()+('  <- companyId went up empty; step 2 did not resolve your depot' if e.rstrip().endswith('for company') else ''))" 2>/dev/null || printf '%.200s' "$1"; }

printf '\nTODP Schedule API - end-to-end proving run\n'
printf 'day=%s  block=%s  vehicles=%s,%s\n\n' "$DAY" "$BLOCK" "$VEH_A" "$VEH_B"

# 1. token -------------------------------------------------------------------
TOKEN=$(curl -s -X POST "$AUTH" -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "username=$TODP_USER" --data-urlencode "password=$TODP_PASS" \
  --data-urlencode 'scope=openapi' --data-urlencode 'grant_type=password' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null)
[ -n "$TOKEN" ] || die "Could not get a token. 400 means a parameter was blank; 401 means a wrong or deactivated credential."
ok "1  got a token (${#TOKEN} chars)"

# 2. find your ids -----------------------------------------------------------
ORG=$(gql '{"query":"query { organization { getOrganization { companies { id name depots { id name } } } } }"}')
FOUND=$(printf '%s' "$ORG" | python3 -c 'import sys,json,os;w=os.environ["DEPOT_NAME"].lower();c=json.load(sys.stdin)["data"]["organization"]["getOrganization"]["companies"];h=[(x["id"],x["name"],d["id"],d["name"]) for x in c for d in x["depots"] if w in d["name"].lower()];print("\t".join(h[0]) if len(h)==1 else ("NONE" if not h else "MANY "+", ".join(y[3] for y in h)))' 2>/dev/null)
case "${FOUND:-NONE}" in
  NONE) bad "2  resolved your depot" "no depot matching '$DEPOT_NAME'. $(why "$ORG")"; die "Stopping: without a depot nothing below can pass." ;;
  MANY*) bad "2  resolved your depot" "matched several: ${FOUND#MANY }"; die "Stopping: narrow DEPOT_NAME to one depot." ;;
esac
CID=$(printf '%s' "$FOUND" | cut -f1); CNAME=$(printf '%s' "$FOUND" | cut -f2)
DID=$(printf '%s' "$FOUND" | cut -f3); DNAME=$(printf '%s' "$FOUND" | cut -f4)
[ -n "$CID" ] && [ -n "$DID" ] || { bad "2  resolved your depot" "matched a depot but could not read its ids"; die "Stopping."; }
ok "2  resolved your depot ($CNAME / $DNAME)"

# 3. register the plan -------------------------------------------------------
R=$(gql '{"query":"mutation P($input: RegisterScheduledJourneysInput!){ schedule { registerScheduledJourneys(input:$input) } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","journeys":[{"externalId":"'"$BLOCK"'-J1","line":"1","distance":12000,"energyConsumptionKwh":15.5,"startPoint":{"name":"Stop A","time":"'"$DAY"'T06:00:00.000Z"},"endPoint":{"name":"Stop B","time":"'"$DAY"'T06:40:00.000Z"}},{"externalId":"'"$BLOCK"'-J2","line":"1","distance":12000,"energyConsumptionKwh":15.1,"startPoint":{"name":"Stop B","time":"'"$DAY"'T07:00:00.000Z"},"endPoint":{"name":"Stop A","time":"'"$DAY"'T07:45:00.000Z"}}]}}}')
if [ "$(val "$R" data.schedule.registerScheduledJourneys)" = "[]" ]; then
  ok "3  registered 2 journeys (empty list = success)"
else
  bad "3  registered 2 journeys" "$(why "$R")"
  die "Stopping: nothing below can pass without a registered plan."
fi

# 4. read the plan back with the same plain date -----------------------------
R=$(gql '{"query":"query S($f: SearchScheduledJourneysFilter!){ schedule { searchScheduledJourneys(filter:$f){ externalId day energyConsumptionKwh } } }","variables":{"f":{"companyId":"'"$CID"'","depotId":"'"$DID"'","day":"'"$DAY"'","blockId":"'"$BLOCK"'"}}}')
N=$(len "$R" data.schedule.searchScheduledJourneys)
STORED=$(val "$R" data.schedule.searchScheduledJourneys 2>/dev/null | python3 -c 'import sys,json;v=json.load(sys.stdin);print(v[0]["day"] if v else "?")' 2>/dev/null || echo "?")
[ "$N" = "2" ] && ok "4  read both back; day stored as $STORED" \
               || bad "4  read both back" "got $N rows. $(why "$R")"

# 5. assign a vehicle over an interval that contains them --------------------
R=$(gql '{"query":"mutation A($input: AssignVehicleJourneysInput!){ schedule { assignVehicleJourneys(input:$input) } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","receivedAt":"'"$NOW"'","assignments":[{"vehicleNumber":"'"$VEH_A"'","from":"'"$DAY"'T05:00:00.000Z","to":"'"$DAY"'T09:00:00.000Z","registeredAt":"'"$NOW"'"}]}}}')
[ "$(val "$R" data.schedule.assignVehicleJourneys)" = "[]" ] \
  && ok "5  assigned $VEH_A to the block" \
  || bad "5  assigned $VEH_A to the block" "$(why "$R")"

# 6. verify ------------------------------------------------------------------
READ_BLOCK='{"query":"query G($f: SearchAssignedJourneysFilter!){ schedule { searchAssignedJourneys(filter:$f){ externalId vehicleNumber type energyConsumptionKwh } } }","variables":{"f":{"companyId":"'"$CID"'","depotId":"'"$DID"'","day":"'"$DAY"'","blockId":"'"$BLOCK"'"}}}'
R=$(gql "$READ_BLOCK")
SUM=$(printf '%s' "$R" | python3 -c 'import sys,json;r=(json.load(sys.stdin).get("data") or {}).get("schedule",{}).get("searchAssignedJourneys") or [];print(len(r),sum(1 for x in r if x.get("energyConsumptionKwh") is not None),",".join(sorted({x["vehicleNumber"] for x in r})) or "-")' 2>/dev/null || echo "0 0 -")
set -- $SUM
[ "${1:-0}" = "2" ] && ok "6  both journeys assigned to ${3:-?} (energy on ${2:-0}/2)" \
                    || bad "6  both journeys assigned" "got ${1:-0} rows. $(why "$R")"

# 7. find one by your own externalId -----------------------------------------
R=$(gql '{"query":"query F($f: FindAssignedJourneyFilter!){ schedule { findAssignedJourney(filter:$f){ externalId vehicleNumber } } }","variables":{"f":{"companyId":"'"$CID"'","depotId":"'"$DID"'","day":"'"$DAY"'","externalId":"'"$BLOCK"'-J1"}}}')
[ "$(val "$R" data.schedule.findAssignedJourney.vehicleNumber)" = "\"$VEH_A\"" ] \
  && ok "7  found J1 by externalId" \
  || bad "7  found J1 by externalId" "$(why "$R")"

# 8. a vehicle swap ----------------------------------------------------------
R=$(gql '{"query":"mutation R($input: RemoveVehicleJourneysInput!){ schedule { removeVehicleJourneys(input:$input){ removedJourneys errors } } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","from":"'"$DAY"'T06:50:00.000Z","to":"'"$DAY"'T09:00:00.000Z","vehicleNumber":"'"$VEH_A"'","receivedAt":"'"$NOW"'","registeredAt":"'"$NOW"'"}}}')
[ "$(val "$R" data.schedule.removeVehicleJourneys.removedJourneys)" = "1" ] \
  && ok "8  released J2 from $VEH_A" \
  || bad "8  released J2 from $VEH_A" "$(why "$R")"

R=$(gql '{"query":"mutation A($input: AssignVehicleJourneysInput!){ schedule { assignVehicleJourneys(input:$input) } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","receivedAt":"'"$NOW"'","assignments":[{"vehicleNumber":"'"$VEH_B"'","from":"'"$DAY"'T06:50:00.000Z","to":"'"$DAY"'T09:00:00.000Z","registeredAt":"'"$NOW"'"}]}}}')
[ "$(val "$R" data.schedule.assignVehicleJourneys)" = "[]" ] \
  && ok "9  assigned J2 to $VEH_B" \
  || bad "9  assigned J2 to $VEH_B" "$(why "$R")"

R=$(gql "$READ_BLOCK")
SPLIT=$(printf '%s' "$R" | python3 -c 'import sys,json;r=(json.load(sys.stdin).get("data") or {}).get("schedule",{}).get("searchAssignedJourneys") or [];print(",".join("%s=%s"%(x["externalId"].rsplit("-",1)[-1],x["vehicleNumber"]) for x in sorted(r,key=lambda y:y["externalId"])) or "-")' 2>/dev/null || echo "-")
[ "$SPLIT" = "J1=$VEH_A,J2=$VEH_B" ] && ok "10 block now split: $SPLIT" \
                                     || bad "10 block now split" "got $SPLIT"

# 9. clean up ----------------------------------------------------------------
gql '{"query":"mutation R($input: RemoveVehicleJourneysInput!){ schedule { removeVehicleJourneys(input:$input){ removedJourneys errors } } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","from":"'"$DAY"'T05:00:00.000Z","to":"'"$DAY"'T09:00:00.000Z","receivedAt":"'"$NOW"'","registeredAt":"'"$NOW"'"}}}' >/dev/null
gql '{"query":"mutation R($input: RemoveScheduledJourneysInput!){ schedule { removeScheduledJourneys(input:$input){ removedJourneys errors } } }","variables":{"input":{"companyId":"'"$CID"'","depotId":"'"$DID"'","blockId":"'"$BLOCK"'","day":"'"$DAY"'","from":"'"$DAY"'T05:00:00.000Z","to":"'"$DAY"'T09:00:00.000Z"}}}' >/dev/null
R=$(gql "$READ_BLOCK")
LEFT=$(len "$R" data.schedule.searchAssignedJourneys)
[ "$LEFT" = "0" ] && ok "11 cleaned up; block reads back empty" \
                  || bad "11 cleaned up" "$LEFT row(s) still in $BLOCK"

printf '\n  %d passed, %d failed\n' "$PASS" "$FAIL"
if [ "$FAIL" -eq 0 ]; then
  printf '  Your integration path works end to end.\n\n'; exit 0
else
  printf '  Send the FAIL lines to support@tenix.eu.\n\n'; exit 1
fi

Run it:

bash
bash prove-it.sh

It prompts for the three things it needs. To run it unattended instead, set TODP_USER, TODP_PASS and DEPOT_NAME beforehand and it will not ask; without a terminal to prompt from, a missing one is an error rather than a hang.

DEPOT_NAME is matched case-insensitively against the depot names your credential can see, so a fragment is enough as long as it picks out exactly one. If it matches none or several, the script says which and stops.

Eleven PASS lines and the path is proven. The script stops at the first failure that makes the rest meaningless rather than reporting a cascade, and exits non-zero, so it also works as a smoke test in a pipeline. Where an error is one of the confusing ones it adds a hint: an empty company in a FORBIDDEN message, for instance, means the id never made it into the request rather than that your permissions are wrong.

What it does not cover

It exercises the recommended two-step path. The one-step path (§10) is a single substitution if you want to prove that instead. It also stops at assignment: comparing plan against reality is §16.

16. Compare plan against actual

Pushing a plan is worth doing because you can later compare it with what was driven. That lives in the journey domain rather than schedule, needs the JOURNEY permission, and is a different shape: one query covers a whole day for a company, and details is a union, so it needs inline fragments.

graphqlwhat was actually driven
query Actual($cid: String!, $day: Date!) {
  journey {
    searchJourneySummaries(filter: { companyId: $cid, day: $day }, pagination: { offset: 0, limit: 500 }) {
      totalCount
      items {
        id vehicleInternalNumber restriction
        start { time } end { time }
        details {
          __typename
          ... on ElectricDetails { distanceCan consumption { total average } }
          ... on FuelDetails    { distanceCan consumption { total average } }
        }
      }
    }
  }
}
Planned distance is metres. Actual distance is kilometres.

ScheduledJourney.distance and AssignedJourney.distance are Int metres. details.distanceCan is a Float in kilometres. Any comparison needs a factor of 1000, and without it every vehicle appears to have driven a thousandth of its plan. This is the easiest way to produce a confidently wrong report from this API.

Three things to settle before you read anything into the numbers:

  • Filter on restriction. NONE excludes journeys the platform does not trust, which will make actual look lower than planned. Group by restriction first to see how much you are excluding, rather than dropping it silently.
  • Join on the vehicle, not the journey. An actual journey carries vehicleInternalNumber, which is the same value as vehicleNumber on the assignment side, but it carries none of your identifiers. There is no externalId on this side, so compare totals per vehicle per day rather than trying to pair individual journeys.
  • Ask for recent days. These queries get slower the further back you ask and eventually fail, so a backfill should walk forward in small steps rather than requesting a wide range at once.

start.time and end.time in this domain come back with no timezone offset, unlike every other timestamp in the API. Do not parse them as UTC.

17. What changed, and when

This endpoint is not versioned, so changes land in place. If you built against an earlier version of this page, these are the ones that affect you.

DateChangeWhat to do
2026-09 assignJourneys became registerAssignedJourneys, taking RegisterAssignedJourneysInput! and returning [String!]! instead of a nullable Boolean. Rename the operation and the input type, and treat [] as success rather than true. A client still calling the old name gets Cannot query field 'assignJourneys'.
2026-09 JourneyInput gained energyConsumptionKwh and socLossPercentage; AssignedJourney gained socLossPercentage. Nothing breaks. But the one-step path can now carry energy figures into charge planning, which it could not before (§4).
2026-09-02 day accepts a plain YYYY-MM-DD everywhere in this domain, on writes as well as reads. Nothing breaks; full timestamps still work. New integrations should send the plain date (§13).
2026-09-02 Two overlapping journeys for one vehicle inside a single call are now rejected, naming both. A client-side overlap guard is no longer required, though it still gives a better message.

There is no deprecation notice channel today. If a change here surprises you, support@tenix.eu is the place to say so.

Verified behaviour summary

The behaviours below were measured against the live API on 16 September 2026, on a depot configured for scheduling, and the test data was removed afterwards. The whole sequence is the one §15 runs, so you can reproduce it on your own depot rather than taking it on trust:

  • getOrganization resolves your own ids. A depot matched by name returned its company and depot ids, and those ids then worked for every write and read that followed (§1a).
  • A plain YYYY-MM-DD day works on write and on read. Journeys registered with day: "2030-01-01" read back from a query using the same plain date, and day returned as 2029-12-31T23:00:00Z, depot-local midnight at UTC+1.
  • Two-step (§6–§7): registerScheduledJourneys returned [], both journeys read back with energyConsumptionKwh intact, searchScheduledBlock found the block, assignVehicleJourneys returned [], and both journeys came back assigned. registeredAt on the assigned rows was the value supplied in the request.
  • One-step (§10): registerAssignedJourneys returned []; both energyConsumptionKwh and socLossPercentage round-tripped onto the assigned journey. registeredAt was the server’s clock, identical to the millisecond across the batch.
  • Failure handling: a journey with its end before its start returned ["Start time must be before end time (Block: …, Vehicle: …, Line: …, Start: …, End: …)"]. HTTP 200, no GraphQL errors array, nothing stored.
  • Journey type (§9) is derived from line: the journey carrying one returned SERVICE, the one without returned DEAD_RUN.
  • Filter rules (§8): searchAssignedJourneys with neither blockId nor vehicleNumber returned BlockId or VehicleNumber must be provided; with both, Only one of filters: BlockId, VehicleNumber can be provided.
  • Carryover (§8): a journey running 22:30Z to 00:30Z registered under 2030-01-01 was returned by queries for both 2030-01-01 and 2030-01-02, reporting its own operating day each time.
  • No DEPOT_PARKING rows: a block whose vehicle stood at a depot-named stop for 1h20 between two journeys returned exactly the two journeys written.
  • Timezone normalisation (§13): DST-aware, day only; journey point times read back exactly as sent, less milliseconds.
  • A mid-block vehicle swap (§11) works as described. Releasing the second half of a block from one vehicle and assigning it to another left the block correctly split, with the first journey still on the original vehicle and the second on the replacement, confirmed by a read.
  • Window-scoped removal removes only what the window contains. removeVehicleJourneys over the second half of a two-journey block, scoped to one vehicleNumber, reported removedJourneys: 1 and left the first journey assigned.
  • Removal (§12): removeVehicleJourneys and removeScheduledJourneys reported accurate counts with an empty errors list, on journeys written by either path, and the block read back empty afterwards.
  • No uniqueness enforcement on externalId (§8b).

Four things on this page rest on the schema and on Engineering’s account of the behaviour rather than on that run: the upsert and identity rules in §6, the full validation order and the overlap rules in §7, removeAssignedJourney’s exact-time matching in §12, and the parsing table in §13. They are stated as the contract. If you hit one behaving otherwise, support@tenix.eu would like to know.

Schema reference: tenix.tech/graphql

Depot configuration, credentials & integration questions: your Tenix contact or support@tenix.eu

Saga Tenix AS · www.tenix.eu · +47 47 77 00 70