Recurring Jobs
CRUD endpoints for registering a task that repeats every week, so a dispatcher does not have to re-enter the same job every planning cycle. The core optimizer is never modified by this feature — recurring jobs are expanded into ordinary jobs entries before each solve.
Set RECURRING_ENABLED=true to mount these routes. When unset (default false), no database tables are created, no routes exist, and /optimize/sync behaves exactly as if this feature did not exist.
Authentication
The CRUD endpoints on this page (register/list/get/patch/delete/expand) are not under /optimize/* and use their own, simpler tenant resolution — a single X-API-Key header checked against the RECURRING_API_KEYS environment variable, unrelated to the X-API-Key + X-Client-Key gateway auth described in Authentication. When you instead add a recurring block to /optimize/sync, /optimize/async, /optimize/replan, or /optimize/replan/async, both checks run on that one request: the normal gateway auth (required, hard failure), and this same X-API-Key header read a second time, independently, to resolve which tenant's recurring patterns to inject.
| Situation | CRUD endpoints (this page) | recurring block on an optimize/replan call |
|---|---|---|
RECURRING_API_KEYS unset | resolves to default tenant | resolves to default tenant |
| Keys set, valid key sent | resolves to mapped tenant | resolves to mapped tenant |
| Keys set, header missing | 401 Unauthorized | no injection; solve proceeds normally (gateway auth still applies separately) |
| Keys set, key not recognized | 403 Forbidden | no injection; solve proceeds normally (gateway auth still applies separately) |
This asymmetry is intentional: the CRUD router on this page is a protected resource in its own right (hard 401/403 on bad auth), while a missing or unrecognized key for recurring injection on an optimize/replan call just means “nothing to inject” — it does not affect the gateway auth that call already required.
Register a pattern
/api/v1/recurring-jobsRegisters a new recurring pattern. Runs a real feasibility solve against the supplied resources fleet (using a Haversine-distance matrix from each occurrence's stored lat/lon) before saving anything. If feasible, any pin_after_first knob is resolved by that solve and frozen to a plain fix value — the stored pattern never carries pin_after_first.
Request body
Knobs are sent as parallel lists: index i in every array describes occurrence i of the same task.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
| external_id | string | required | - | Your own stable id for this task. Used in generated job ids: rec::{external_id}::{occurrence_index}::{period_key}. |
| client_ref | string | optional | null | Opaque reference; no PII is stored or interpreted. |
| recurrence | string | optional | weekly | Only weekly is implemented; the data model supports monthly but the expansion logic does not yet. |
| spacing | object | optional | {} | {distinct_days, min_gap_days} — constraints across the pattern's own occurrences. |
| locations | Location[] | required | - | GPS catalog: {index, lat, lon, label} per matrix index referenced below. Used to build the registration-time travel matrix. |
| resources | Resource[] | required | - | The fleet the feasibility solve runs against, and against which pin_after_first knobs are resolved. |
| location_index | int[] | required | - | Matrix index per occurrence. |
| service_time | int[] | required | - | Service duration in seconds per occurrence — same unit as every other timing field in the API's wire format (service_time on a regular job, time_limit_seconds, etc.). 20 minutes is 1200, not 20. |
| day / day_rule | int?[] / string[] | required | - | 0=Monday…4=Friday, or null when free. Rule is one of free, fix, pin_after_first. |
| tw / tw_rule | object?[] / string[] | required | - | {start, end} in seconds from midnight, or null when free. An 8-hour shift starting at midnight is {"start": 0, "end": 28800}, not {"start": 0, "end": 480}. |
| resource / resource_rule | string?[] / string[] | required | - | Resource id, or null when free or not yet pinned. |
| mandatory | bool[] | required | - | Per occurrence; false means it may be dropped for lack of room. |
The three knob rules
| Rule | At registration | Every weekly solve |
|---|---|---|
free | Solver picks freely | Solver picks freely again — nothing remembered |
pin_after_first | Resolved by the registration solve, frozen to fix | Reuses the value chosen at registration |
fix | Always the stored value | Always the stored value |
Example
curl -X POST https://api.fieldgenius.be/api/v1/recurring-jobs \
-H "X-API-Key: fg-4412b9a6e7c64f1b9b2e3a5d8f6c1a02" \
-H "Content-Type: application/json" \
-d '{
"external_id": "derudder-42-weekly",
"client_ref": "cust-derudder-42",
"recurrence": "weekly",
"locations": [
{ "index": 0, "lat": 50.8487, "lon": 3.3458, "label": "depot" },
{ "index": 1, "lat": 50.8748, "lon": 2.8841, "label": "site-A" }
],
"resources": [
{ "id": "team-A", "depot_index": 0, "available_days": [0,1,2,3,4], "tw": {"start": 0, "end": 28800} }
],
"location_index": [1],
"service_time": [1200],
"day": [null], "day_rule": ["free"],
"tw": [null], "tw_rule": ["free"],
"resource": [null], "resource_rule": ["pin_after_first"],
"mandatory": [true]
}'import httpx
payload = {
"external_id": "derudder-42-weekly",
"client_ref": "cust-derudder-42",
"recurrence": "weekly",
"locations": [
{"index": 0, "lat": 50.8487, "lon": 3.3458, "label": "depot"},
{"index": 1, "lat": 50.8748, "lon": 2.8841, "label": "site-A"},
],
"resources": [
{"id": "team-A", "depot_index": 0, "available_days": [0, 1, 2, 3, 4],
"tw": {"start": 0, "end": 28800}},
],
"location_index": [1],
"service_time": [1200],
"day": [None], "day_rule": ["free"],
"tw": [None], "tw_rule": ["free"],
"resource": [None], "resource_rule": ["pin_after_first"],
"mandatory": [True],
}
headers = {"X-API-Key": "fg-4412b9a6e7c64f1b9b2e3a5d8f6c1a02"}
resp = httpx.post("https://api.fieldgenius.be/api/v1/recurring-jobs", json=payload, headers=headers)
print(resp.status_code, resp.json()["id"])Response (201): the resource_rule for occurrence 0 comes back as "fix" with a concrete resource_id — the registration solve resolved the pin immediately.
{
"id": "d2e1f508-24fa-4cb4-965c-f4cf269ba062",
"version": 1,
"active": true,
"items": [
{
"occurrence_index": 0,
"location_index": 1, "lat": 50.8748, "lon": 2.8841, "location_label": "site-A",
"day": null, "day_rule": "free",
"tw": null, "tw_rule": "free",
"resource_id": "team-A", "resource_rule": "fix",
"mandatory": true
}
]
}
List patterns
/api/v1/recurring-jobsReturns all patterns for the resolved tenant. Add ?active_only=true to hide paused patterns.
Get one pattern
/api/v1/recurring-jobs/{id}Returns the pattern with every occurrence's current knob values and pins. 404 if not found in the resolved tenant.
Edit a pattern
/api/v1/recurring-jobs/{id}Partial update with explicit absent/value/null semantics, plus optimistic locking.
| How a field is sent | What it means |
|---|---|
| Absent from body | Leave exactly as stored |
| Present with a value | Replace with the new value |
Present, set to null | Clear it (e.g. unpin a vehicle) |
You must include expected_version: the version you last read. If another request modified the pattern in the meantime, the server returns 409 Conflict instead of silently overwriting it. A PATCH that re-introduces a pin_after_first knob, or sets active: true on a paused pattern, must include a fresh resources array because it triggers a re-solve.
Example: move a time window
curl -X PATCH https://api.fieldgenius.be/api/v1/recurring-jobs/d2e1f508-... \
-H "X-API-Key: fg-4412b9a6e7c64f1b9b2e3a5d8f6c1a02" \
-H "Content-Type: application/json" \
-d '{
"expected_version": 1,
"tw": [{"start": 7200, "end": 10800}],
"tw_rule": ["fix"]
}'
Delete a pattern
/api/v1/recurring-jobs/{id}Permanently removes the pattern and its occurrences. Returns 204 No Content. This is irreversible — to pause a pattern reversibly, PATCH {"active": false} instead and reactivate later with {"active": true} (plus a fresh resources array for the re-solve).
Preview expansion
/api/v1/recurring-jobs/expandShows what jobs a given period would produce, without solving or storing anything. Useful for inspecting which pins are already frozen before a real solve runs.
curl -X POST https://api.fieldgenius.be/api/v1/recurring-jobs/expand \
-H "X-API-Key: fg-4412b9a6e7c64f1b9b2e3a5d8f6c1a02" \
-H "Content-Type: application/json" \
-d '{"period_key": "2026-W26"}'
Injecting into a solve
Add a single recurring block to an otherwise ordinary request on POST /optimize/sync, POST /optimize/async, POST /optimize/replan, or POST /optimize/replan/async:
{
"recurring": { "period_key": "2026-W26", "requested_days": [0, 2] },
"resources": [ ... ],
"jobs": [ ... ]
}
requested_days filters which fixed-day occurrences are injected (free-day occurrences always pass); null means all days. If the recurring block is absent entirely, the endpoint behaves exactly as it always has — no injection, full backward compatibility. On /optimize/replan/replan/async specifically, injection runs after the replan transform is applied, not before.
Errors
| Status | Code | Resolution |
|---|---|---|
| 401 | UNAUTHORIZED | X-API-Key header missing while RECURRING_API_KEYS is configured. |
| 403 | FORBIDDEN | Key supplied but not present in the RECURRING_API_KEYS map. |
| 404 | NOT_FOUND | Pattern id does not exist for the resolved tenant. |
| 409 | CONFLICT | expected_version on a PATCH does not match the current stored version. |
| 422 | VALIDATION_ERROR | The registration or PATCH feasibility solve returned infeasible; nothing was persisted. |
See Example: Recurring Jobs Walkthrough for an end-to-end scenario across multiple weeks.