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.

Disabled by default

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

This is a second, separate X-API-Key check

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.

SituationCRUD endpoints (this page)recurring block on an optimize/replan call
RECURRING_API_KEYS unsetresolves to default tenantresolves to default tenant
Keys set, valid key sentresolves to mapped tenantresolves to mapped tenant
Keys set, header missing401 Unauthorizedno injection; solve proceeds normally (gateway auth still applies separately)
Keys set, key not recognized403 Forbiddenno 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

POST /api/v1/recurring-jobs

Registers 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.

NameTypeRequiredDefaultDescription
external_idstringrequired-Your own stable id for this task. Used in generated job ids: rec::{external_id}::{occurrence_index}::{period_key}.
client_refstringoptionalnullOpaque reference; no PII is stored or interpreted.
recurrencestringoptionalweeklyOnly weekly is implemented; the data model supports monthly but the expansion logic does not yet.
spacingobjectoptional{}{distinct_days, min_gap_days} — constraints across the pattern's own occurrences.
locationsLocation[]required-GPS catalog: {index, lat, lon, label} per matrix index referenced below. Used to build the registration-time travel matrix.
resourcesResource[]required-The fleet the feasibility solve runs against, and against which pin_after_first knobs are resolved.
location_indexint[]required-Matrix index per occurrence.
service_timeint[]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_ruleint?[] / string[]required-0=Monday…4=Friday, or null when free. Rule is one of free, fix, pin_after_first.
tw / tw_ruleobject?[] / 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_rulestring?[] / string[]required-Resource id, or null when free or not yet pinned.
mandatorybool[]required-Per occurrence; false means it may be dropped for lack of room.

The three knob rules

RuleAt registrationEvery weekly solve
freeSolver picks freelySolver picks freely again — nothing remembered
pin_after_firstResolved by the registration solve, frozen to fixReuses the value chosen at registration
fixAlways the stored valueAlways 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

GET /api/v1/recurring-jobs

Returns all patterns for the resolved tenant. Add ?active_only=true to hide paused patterns.

Get one pattern

GET /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

PATCH /api/v1/recurring-jobs/{id}

Partial update with explicit absent/value/null semantics, plus optimistic locking.

How a field is sentWhat it means
Absent from bodyLeave exactly as stored
Present with a valueReplace with the new value
Present, set to nullClear 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

DELETE /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

POST /api/v1/recurring-jobs/expand

Shows 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

StatusCodeResolution
401UNAUTHORIZEDX-API-Key header missing while RECURRING_API_KEYS is configured.
403FORBIDDENKey supplied but not present in the RECURRING_API_KEYS map.
404NOT_FOUNDPattern id does not exist for the resolved tenant.
409CONFLICTexpected_version on a PATCH does not match the current stored version.
422VALIDATION_ERRORThe registration or PATCH feasibility solve returned infeasible; nothing was persisted.

See Example: Recurring Jobs Walkthrough for an end-to-end scenario across multiple weeks.

FieldGenius VRP API documentation. Generated from the engineering source of truth (.tex docs and app/ source).