Create and update operations locations
There are two ways to get an Operations Location into the system:
| Approach | Endpoint | Use it when |
|---|---|---|
| REST resource | POST /api/v2/operationslocation | You are creating a brand new location and want to define its Group and Time Windows inline. |
| Bulk task | POST /api/v2/microservices/bulk/task with type: add_update_operations_location | You are synchronising locations from an external master system, need create-or-update (upsert) semantics, or need to link allowed vehicle types. |
Approach 1: create with the REST resource
The Operations Location API supports creating an Operations Location along with its related Time Windows and Operations Location Group in a single API request, rather than creating them sequentially and linking them by IDs.
This significantly simplifies the setup process for new locations.
How it works
Instead of providing the group ID or creating time_windows separately, you can embed the full definition of the group and the list of time windows directly in the POST request body for the location.
Example Request
Below is an example of a single request that creates:
- A new Operations Location ("Location name").
- A new Operations Location Group (code "E9119").
- A Time Window ("Thursday Day 1") for the location.
POST /api/v2/operationslocation
{
"address": "Location address",
"code": "QHCode",
"name": "Location name",
"postal_code": "520710",
"point": {
"coordinates": [
101.917644661626,
12.15892431398393
],
"type": "Point"
},
"group": {
"code": "E9119",
"project": "/api/v2/project/888"
},
"project": "/api/v2/project/888",
"time_windows": [
{
"time_window": {
"name": "Thursday Day 1",
"open_time_ts": "1900-01-01T08:30:00+00:00",
"close_time_ts": "1900-01-01T13:00:00+00:00",
"recurrences": "DTSTART:20240905T184432Z\nRRULE:FREQ=DAILY;BYDAY=TH",
"strict": true,
"public_holiday": false,
"project": "/api/v2/project/888"
}
}
]
}
By using this structure, the server handles the creation and linking of all entities in a single transaction.
Approach 2: upsert with the add_update_operations_location bulk task
The add_update_operations_location bulk task writes the location, its time windows, its allowed vehicle types and its default category in a single transaction, and it is idempotent: the same request can be replayed and it will update the existing location instead of creating a duplicate.
This is the approach to use when a TMS/OMS/ERP owns the location master and pushes changes to SWAT on a schedule.
Step 1: Retrieve the available vehicle types
Vehicle types are linked by their numeric IDs, so first resolve the IDs for the project. Use the generic data endpoint and restrict the payload with only_fields.
GET /api/v2/microservices/get?model=vehicletype&project_id=888&only_fields=id,name
{
"meta": {
"has_more": false,
"next": null,
"next_offset": null
},
"objects": [
{ "id": 41, "name": "small_van" },
{ "id": 42, "name": "light_truck" },
{ "id": 43, "name": "heavy_truck" }
]
}
Restrictions are a three-way link between a location, a vehicle type and a simulation, so you also need the target simulation IDs. Retrieve them the same way with model=simulation&project_id=<project_id>&only_fields=id,name,start_time. Linking a vehicle type to a simulation template makes the restriction apply to every simulation later created from that template.
Step 2: Retrieve the available categories
Skip this step if the location does not need a category. Categories are referenced by their numeric IDs too, so resolve them the same way. Filtering out invalidated categories avoids picking one the task will reject.
GET /api/v2/microservices/get?model=operationslocationcategory&project_id=888&only_fields=id,name,external_id¬__is_invalidated=true
{
"meta": {
"has_more": false,
"next": null,
"next_offset": null
},
"objects": [
{ "id": 77, "name": "Chilled dock", "external_id": "CAT-CHILLED" },
{ "id": 78, "name": "Residential", "external_id": "CAT-RES" },
{ "id": 79, "name": "Secure compound", "external_id": "CAT-SEC" }
]
}
Pass the chosen ID as operations_location.default_category_id in the next step — the example uses 77 (Chilled dock).
If your system already knows the category by its own identifier, filter on it directly with &external_id=CAT-CHILLED instead of matching on name.
Step 3: Create or update the location
Post the task to POST /api/v2/microservices/bulk/task. The example below creates (or updates) location EXT-001, assigns it the Chilled dock category (77), allows small_van (41) and light_truck (42) in simulations 118936 and 120419, and attaches two time windows.
POST /api/v2/microservices/bulk/task
{
"project_id": 888,
"type": "add_update_operations_location",
"execution_mode": "sync",
"payload": {
"operations_location": {
"external_id": "EXT-001",
"name": "Jurong Cross Dock",
"address": "1 Jurong Port Road",
"postal_code": "619092",
"lat": 1.31682,
"lon": 103.71083,
"group_id": 4501,
"default_category_id": 77,
"is_invalidated": false,
"data": {
"remarks": "Dock 3 only"
}
},
"vehicle_types": {
"41": [118936, 120419],
"42": [118936]
},
"time_windows": [
{
"name": "Weekday morning",
"open_time_ts": "1900-01-01T08:30:00+00:00",
"close_time_ts": "1900-01-01T13:00:00+00:00",
"recurrences": "DTSTART:20240905T184432Z\nRRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"
},
{
"name": "Saturday",
"open_time_ts": "1900-01-01T09:00:00+00:00",
"close_time_ts": "1900-01-01T12:00:00+00:00",
"recurrences": "DTSTART:20240905T184432Z\nRRULE:FREQ=DAILY;BYDAY=SA"
}
]
}
}
Request fields
The task envelope carries the access-control context; the payload carries the location itself.
| Field | Type | Description |
|---|---|---|
project_id | Integer | Required. Project the location belongs to. The caller must have allow_manage rights on it. Must be set here — passing project_id inside payload.operations_location is rejected. |
simulation_id | Integer | Not used by this task. Omit it. If sent it is only validated against the project (the request fails with Simulation does not exist if it does not belong there) and is otherwise ignored — simulation scoping for this task comes from payload.vehicle_types. |
type | String | Required. Must be add_update_operations_location. |
execution_mode | Enum | sync or async. Defaults to async. |
payload.operations_location | Object | Required. The location definition. See below. |
payload.vehicle_types | Map<String, List<Int>> | Vehicle type ID → list of simulation IDs in which that vehicle type is allowed to serve the location. Keys are the numeric vehicle type IDs as strings; each list must be non-empty and contain no duplicates. See Simulation IDs are mandatory. |
payload.time_windows | List<Object> | Time windows to link to the location. |
payload.propagate_vehicle_types_from | DateTime (ISO8601) | Optional. See Propagating to descendant simulations. |
operations_location
| Field | Type | Description |
|---|---|---|
external_id | String | The identifier from your system. This is the upsert key — see Upsert behaviour. |
name | String | Display name. Defaults to "Operations Location". |
lat | Float | Required. Latitude. |
lon | Float | Required. Longitude. |
address | String | Free-form address. |
postal_code | String | Postal code. |
group_id | Integer | OperationsLocationGroup to attach the location to. Must belong to the same project and must not be invalidated. |
default_category_id | Integer | Project-scoped OperationsLocationCategory used as the location's default category. Must belong to the same project and must not be invalidated. See Default category. |
is_invalidated | Boolean | Soft-deletion flag. Defaults to false. |
data | Object | Custom per-location data. |
The task deliberately does not accept id, project_id, code, point, h3, calculation_params, search_index, categories_ids, created_at or modified_at. Sending any of them fails validation rather than being silently ignored. Use coordinates (lat/lon) instead of point, and set calculation_params through the Operations Location API.
time_windows
| Field | Type | Description |
|---|---|---|
name | String | Free-form name. |
open_time_ts | DateTime (ISO8601) | Window opening time. |
close_time_ts | DateTime (ISO8601) | Window closing time. |
recurrences | String | Serialized recurrence rule (DTSTART/RRULE). |
Time windows here also reject id, project_id, strict, public_holiday, public_holidays, created_at and modified_at.
Upsert behaviour
The task looks for an existing location by project_id + external_id.
- No match — a new
OperationsLocationis created. - Match — the existing location is updated in place.
name,group_id,address,postal_code,lat/lon,dataandis_invalidatedare all overwritten with what you send.
is_invalidated defaults to false. If you omit it when updating a location that was previously soft-deleted, the location is reactivated. Send "is_invalidated": true explicitly to keep it invalidated.
Because the whole location object is overwritten, always send the complete desired state — omitted optional fields are cleared, not preserved.
Vehicle types and time windows are synchronised, not merged
Both vehicle_types and time_windows describe the full desired state of the links:
- Links present in the payload but missing in the database are created.
- Links present in the database but missing from the payload are deleted.
So sending "vehicle_types": {} removes every vehicle type restriction from the location, and omitting a previously linked time window unlinks it. The response reports exactly which link rows were created and deleted.
Time windows are matched against existing OperationsTimeWindow rows in the same project on the triple (open_time_ts, close_time_ts, recurrences). A matching window is reused; otherwise a new one is created. name is not part of the match, so an existing window is reused even if you give it a different name.
Because both collections are synchronised rather than merged, an update that omits them is destructive. Sending only operations_location to correct a typo in an address will strip every vehicle type restriction and every time window from that location. Always send the complete desired state.
Simulation IDs are mandatory for vehicle type links
A vehicle type restriction is a three-way link between a location, a vehicle type and a simulation — the simulation is a required part of the record, so there is no project-wide form of the restriction. This means:
- You cannot pass an empty list:
"41": []fails validation. - A link created for simulation
118936has no effect in any other simulation. To cover a recurring plan, link against the simulation template — the restriction is then copied into every simulation created from it — and usepropagate_vehicle_types_fromfor simulations that already exist.
What happens if you omit vehicle_types or time_windows
Both fields default to empty, so omitting them is the same as sending an empty collection.
| Omitted | Effect on the location | Effect on optimization |
|---|---|---|
vehicle_types | All existing vehicle type links are deleted. | The location becomes unrestricted — no {"or": [...]} clause is added to node.vehicle_labels, so any vehicle may serve it, subject only to the booking's own label requirements. |
time_windows | All existing time window links are deleted. | Bookings referencing the location are no longer time-enriched. A booking that supplies its own min_/max_pickup_time (or dropoff) is unaffected, because location time windows are only applied when the booking leaves those fields empty. A booking with neither ends up with no time constraint at that stop. |
Unlike calculation_params, time windows have no group-level fallback — they are resolved per location only. Removing them from a location leaves nothing to inherit.
Default category
An Operations Location Category is a project-scoped label that carries its own calculation_params — service times, entry/exit times and cumulative limitations shared by every location of that kind.
Categories are attached to locations by an assignment record that links a location, a category and an optional simulation. default_category_id is a shortcut for the assignment without a simulation, which is the category the location uses in every simulation:
| You send | Result |
|---|---|
| An id | The default assignment is created, or repointed to the new category if one already exists. |
| Nothing | Any existing default assignment is deleted, the same as for vehicle_types and time_windows. |
The category is validated inside the write transaction: it must belong to the same project as the location and must not be invalidated, otherwise the entire task fails and nothing is persisted.
The task never touches simulation-scoped assignments. To override the category for one simulation only, use the category assignment API directly.
A category replaces the location's own calculation_params rather than filling gaps in them — see Precedence. Assigning a default category to a location that already carries its own parameters will change how that location is planned.
The default_category_id returned in the response is read back from the database after the assignment is written, so it reflects the stored state rather than what you sent.
Propagating to descendant simulations
When you link vehicle types to a simulation template, set propagate_vehicle_types_from to an ISO8601 timestamp to also apply those links to simulations already created from that template whose start_time is at or after the given moment.
{
"propagate_vehicle_types_from": "2026-09-01T00:00:00+00:00",
"vehicle_types": {
"41": [112987]
}
}
Here 112987 is a template. Every simulation created from it that starts on or after 1 September 2026 also gets the link. Simulations that started earlier are left untouched, so past plans are not rewritten. Explicit and propagated links are de-duplicated.
Response
In sync mode the result is returned inline:
{
"result": {
"operations_location_id": 90210,
"operations_location": {
"id": 90210,
"project_id": 888,
"external_id": "EXT-001",
"name": "Jurong Cross Dock",
"group_id": 4501,
"default_category_id": 77,
"address": "1 Jurong Port Road",
"postal_code": "619092",
"is_invalidated": false,
"lat": 1.31682,
"lon": 103.71083,
"data": { "remarks": "Dock 3 only" }
},
"operations_locations_created_id": 90210,
"operations_locations_updated_id": null,
"operations_locations_vehicle_types_created_ids": [7001, 7002, 7003],
"operations_locations_vehicle_types_deleted_ids": [],
"operations_locations_time_windows_created_ids": [8001, 8002],
"operations_locations_time_windows_deleted_ids": [],
"time_windows_created_ids": [5501]
}
}
operations_locations_created_id and operations_locations_updated_id are mutually exclusive — exactly one is populated, which tells you whether the call created a new location or updated an existing one.
In async mode the endpoint returns a task identifier instead:
{ "task_id": "52fb6f16-ff16-4db9-933f-607fcb61e1ee" }
Poll for completion and read the same result payload with:
GET /api/v2/microservices/get?model=bulktask&id=52fb6f16-ff16-4db9-933f-607fcb61e1ee
add_update_operations_location handles one location per task and has no size limit in sync mode, so sync is usually the simpler choice — you get the created and deleted IDs back immediately. Use async when you are queueing a large number of locations and do not want to block on each one.
All-or-nothing writes
Every write in the task — the location, the time windows, the vehicle type links and the default category assignment — happens inside one database transaction. If validation of the group or category fails, or any write fails, nothing is persisted.
This guarantee covers one task message. When you send several at once, see Sending several locations in one request below.
Sending several locations in one request
A single task carries exactly one location — operations_location is an object, not a list. To cover several locations in one call, post a JSON array of task messages instead of a single object. Each element is a complete message with its own project_id, type and payload.
POST /api/v2/microservices/bulk/task
[
{
"project_id": 888,
"type": "add_update_operations_location",
"payload": {
"operations_location": {
"external_id": "EXT-001",
"name": "Jurong Cross Dock",
"lat": 1.31682,
"lon": 103.71083
}
}
},
{
"project_id": 888,
"type": "add_update_operations_location",
"payload": {
"operations_location": {
"external_id": "EXT-002",
"name": "Tuas Depot",
"lat": 1.32115,
"lon": 103.63790
}
}
}
]
The response is the list of submitted messages echoed back in order, each with its result filled in — the same result object described in Response.
The batch form behaves differently from a single message in ways that matter:
| Single message | Array of messages | |
|---|---|---|
| Execution | sync or async | Always synchronous. execution_mode is ignored and no task_id is returned. |
| Permissions | Checked for the one project | Checked for every message before any of them run. One project you cannot manage means nothing executes. |
| Failure | Nothing is written | Locations already processed stay written |
There is no transaction spanning the array. Each location is committed in its own transaction as the server works through the list in order, so a failure partway through leaves the earlier locations already applied while the later ones are never attempted.
The request fails as a whole with a 500 and an error message, and no per-item results — so the response does not tell you how far it got. You have to re-read the locations to find out.
Because the task is idempotent on project_id + external_id, the safe recovery is simply to resend the whole batch: locations that were already written are updated in place rather than duplicated.
No size limit is enforced on the array, but the whole batch runs inline within a single HTTP request, so large batches risk a timeout rather than a validation error. For a bulk synchronisation of many locations, prefer one message per location in async mode — each gets its own task_id and fails independently — and keep the array form for small batches you are willing to resend in full.
Related
- Restricting access of certain vehicle types to locations — how the vehicle type links turn into routing constraints.
- Customer master — how locations hydrate bookings, and
calculation_params. - Bulk Updates — the other bulk task types.