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.
Throughout, replace the placeholders <YOUR_COMPANY_ID> and <YOUR_DEPOT_ID> with the values your Tenix contact provides.
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
getOrganizationwill list depots in that state. See §5. - Give you your
companyIdanddepotId, and confirm your API credentials carry theSCHEDULEpermission.
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.
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.
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
| Item | Value |
|---|---|
| GraphQL endpoint | https://openapi.platform.tenix.eu/graphql |
| Auth endpoint | https://openapi.platform.tenix.eu/auth |
| Method (GraphQL) | POST, Content-Type: application/json |
| Permission needed | SCHEDULE |
| Full schema reference | tenix.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:
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:
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.
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.
(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.
| Path | Use 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.
| Capability | Two-step | One-step |
|---|---|---|
energyConsumptionKwh per journey | yes | yes |
socLossPercentage per journey | no | yes |
| Stop identifiers and coordinates | yes: StopPointInput takes id, latitude, longitude | no: JourneyPointInput is name and time only |
allowOverassignment | yes | no |
Who sets registeredAt | you, per assignment | the server’s clock |
| A separate plan you can read back | yes: searchScheduledJourneys | no; nothing is registered as scheduled |
Writes to searchAssignmentChanges | yes | no |
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.
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.
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.
mutation Plan($input: RegisterScheduledJourneysInput!) {
schedule {
registerScheduledJourneys(input: $input)
}
}
{
"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:
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"} } ] } }
}'
{"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 handlefindAssignedJourneyaccepts, 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
energyConsumptionKwhif you have an estimate. It is what makes this path worth using (§4). - Send stop-point coordinates.
latitude,longitudeandidare all optional onStopPointInput; onlynameandtimeare required. Send them anyway. They are stored and other parts of the platform use them. Note the read side returns onlynameandtime, 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 toline, thenblockId), then its start and end times. Send the same journey twice and the second write replaces the first, so retries are harmless. Butdayis 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, varyexternalIdper 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.
mutation Assign($input: AssignVehicleJourneysInput!) {
schedule {
assignVehicleJourneys(input: $input)
}
}
{
"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:
- The block has no journeys registered for that depot and day. Verify with
searchScheduledJourneysusing the samedayvalue before assigning. - 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”.
| Field | Meaning |
|---|---|
registeredAt (per assignment) | When your system decided this assignment |
receivedAt (per call) | When it reached your integration boundary |
What gets validated, in order
| Check | Message |
|---|---|
| depot configured for scheduling | Depot is not configured (<depotId>) |
blockId non-blank | Block ID must not be empty |
| day not before depot-local today | Assignment day must not be earlier than today |
| journey ends no earlier than today | End time must not be earlier than today (…) |
| end after start | Start time must be before end time (…) |
| a vehicle identifier present | Vehicle identifier must be provided (…) |
| no overlap for that vehicle | Vehicle is already assigned within provided time scope (…) |
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
- It does not check that the vehicle exists, only that some identifier was supplied. A
vehicleNumberthat 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. - 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.
- 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.
query Fleet($f: SearchVehiclesFilter!) {
vehicle {
searchVehicles(filter: $f) {
id internalNumber divisionId fuelType deleted
}
}
}
{ "f": {
"companyId": "<YOUR_COMPANY_ID>",
"internalNumber": "^101$",
"deleted": false
} }
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.
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 }
}
}
}
{
"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
| Operation | Rule |
|---|---|
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.
query Find($f: FindAssignedJourneyFilter!) {
schedule {
findAssignedJourney(filter: $f) {
blockId day vehicleNumber type line externalId
startPoint { name time }
endPoint { name time }
}
}
}
{ "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.
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.
externalIdis 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
searchAssignedJourneysbyblockId. A duplicateexternalIdhides journeys from find but never from search.
Three things about the rows you get back
- The
dayreads back as depot-local midnight, not what you sent. For a Norwegian depot,2030-01-01comes back as2029-12-31T23:00:00Zin winter and…T22:00:00Zin summer. See §13. - You will get rows for the previous operating day.
searchAssignedJourneysdeliberately 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 under2030-01-01and running22:30Zto00:30Zcame back from both a2030-01-01and a2030-01-02query, reportingdayas its own operating day both times. Read thedayfield 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
JourneyTypeenum includesDEPOT_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 notypefilter 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:
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.
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.
mutation RegisterAssigned($input: RegisterAssignedJourneysInput!) {
schedule {
registerAssignedJourneys(input: $input)
}
}
{
"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" }
}
]
}
}
{"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
JourneyInputtakesvehicleNumber,distance,line,startPoint,endPoint,externalId,energyConsumptionKwhandsocLossPercentage.socLossPercentageis available only here: on the two-step path the platform derives it rather than accepting it.JourneyPointInputis{ name, time }only: no stop identifiers, no coordinates. Use §6 if you need those.registeredAtis the server’s clock, not a value you supply. Every journey in one call shares the same timestamp to the millisecond.allowOverassignmentis unavailable here.searchScheduledJourneyswill not show these journeys, because they were never registered as scheduled. That is correct, not a fault.searchAssignmentChangesstays 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.
mutation Unassign($input: RemoveVehicleJourneysInput!) {
schedule {
removeVehicleJourneys(input: $input) { removedJourneys errors }
}
}
{ "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"
} }
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
mutation Cancel($input: RemoveScheduledJourneysInput!) {
schedule {
removeScheduledJourneys(input: $input) { removedJourneys errors }
}
}
{ "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
mutation Remove($input: RemoveAssignedJourneyInput!) {
schedule {
removeAssignedJourney(input: $input)
}
}
{ "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.
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
- Send
dayas a plainYYYY-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 carryZor an explicit offset. - Zero-pad the date, and always include
Zor an offset on a timestamp. The gateway expandsdaywith JavaScript’snew Date(), which is lenient in ways that matter:You send Parsed as Safe? 2030-01-01 UTC midnight ✅ deterministic 2030-01-01T00:00:00Z UTC midnight ✅ deterministic 2030-01-01T06:00:00+02:00 04:00 UTC ✅ offsets are honoured 2030-01-01T00:00:00 the gateway server’s local midnight ❌ environment-dependent 2030-1-1 the gateway’s local midnight, not the same as the padded form ❌ - Validate dates client-side. A malformed
daydoes not come back as a validation message. It surfaces asINTERNAL_SERVER_ERRORwithCannot read properties of undefined (reading 'status'). The message will not help you diagnose it, so check the format before you send. - You cannot backdate. Both the block’s
dayand each journey’s end time are compared against depot-local today. - What you read back is depot-local midnight. The depot has its own timezone, and
dayis 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”. Onlydayshifts; journeystartPoint.timeandendPoint.timeread back exactly as sent.Compare parsed instants, or compare against the value the API returned. Never the strings.Sent ( day)Read back Depot offset 2030-01-01 2029-12-31T23:00:00Z UTC+1 (CET, winter) 2026-08-20 2026-08-19T22:00:00Z UTC+2 (CEST, summer) - A block that spans local midnight belongs to the operating day you registered it under. Journey times are absolute instants; only
dayis a bucket, and nothing splits a block at midnight. See the carryover note in §8. - Journey times are UTC. Send
startPoint.time/endPoint.timeas full timestamps withZ. - Planned distance is metres; actual distance is kilometres.
ScheduledJourney.distanceandAssignedJourney.distanceareIntmetres. The actual distance a vehicle drove, in thejourneydomain, is aFloatin kilometres. Any plan-versus-actual comparison needs a factor of 1000. - Vehicle existence is not validated. A
vehicleNumbermatching no vehicle is accepted and stored. Check your fleet numbers before you send (§7). - Vary
externalIdper day if your timetable repeats a line at identical clock times, becausedayis not part of a journey’s identity (§6).
14. Troubleshooting
| Message | Meaning / 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 today | Use 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 empty | blockId is blank. |
BlockId or VehicleNumber must be provided | Add one, but not both, to a searchAssignedJourneys filter. |
Only one of filters: BlockId, VehicleNumber can be provided | You sent both. Pick one and filter the rest client-side. |
BlockId or ExternalId must be provided | A searchScheduledBlock filter needs one of them; blocks cannot be enumerated. |
At least one depotId must be provided | searchScheduledBlock 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 / UNAUTHENTICATED | Token expired; re-authenticate (§2). Tokens last one hour. |
User does not have permissions set for company | The 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 implemented | The 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. |
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.
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.
#!/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 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.
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.
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 } }
}
}
}
}
}
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.NONEexcludes journeys the platform does not trust, which will make actual look lower than planned. Group byrestrictionfirst 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 asvehicleNumberon the assignment side, but it carries none of your identifiers. There is noexternalIdon 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.
| Date | Change | What 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:
getOrganizationresolves 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-DDdayworks on write and on read. Journeys registered withday: "2030-01-01"read back from a query using the same plain date, anddayreturned as2029-12-31T23:00:00Z, depot-local midnight at UTC+1. - Two-step (§6–§7):
registerScheduledJourneysreturned[], both journeys read back withenergyConsumptionKwhintact,searchScheduledBlockfound the block,assignVehicleJourneysreturned[], and both journeys came back assigned.registeredAton the assigned rows was the value supplied in the request. - One-step (§10):
registerAssignedJourneysreturned[]; bothenergyConsumptionKwhandsocLossPercentageround-tripped onto the assigned journey.registeredAtwas 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 GraphQLerrorsarray, nothing stored. - Journey type (§9) is derived from
line: the journey carrying one returnedSERVICE, the one without returnedDEAD_RUN. - Filter rules (§8):
searchAssignedJourneyswith neitherblockIdnorvehicleNumberreturnedBlockId or VehicleNumber must be provided; with both,Only one of filters: BlockId, VehicleNumber can be provided. - Carryover (§8): a journey running
22:30Zto00:30Zregistered under2030-01-01was returned by queries for both2030-01-01and2030-01-02, reporting its own operating day each time. - No
DEPOT_PARKINGrows: 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,
dayonly; 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.
removeVehicleJourneysover the second half of a two-journey block, scoped to onevehicleNumber, reportedremovedJourneys: 1and left the first journey assigned. - Removal (§12):
removeVehicleJourneysandremoveScheduledJourneysreported accurate counts with an emptyerrorslist, 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