# Create a plan Source: https://developers.kardinal.ai/api-reference/plan/create-a-plan /openapi.yaml post /plans The plan id is generated by the service: sending an `id` in the payload is rejected with a `400`. Use the id returned in the response to address the plan on the other endpoints. # Delete a plan Source: https://developers.kardinal.ai/api-reference/plan/delete-a-plan /openapi.yaml delete /plans/{planId} # Retrieve a plan Source: https://developers.kardinal.ai/api-reference/plan/retrieve-a-plan /openapi.yaml get /plans/{planId} # Retrieve the latest state of a plan Source: https://developers.kardinal.ai/api-reference/plan/retrieve-the-latest-state-of-a-plan /openapi.yaml get /plans/{planId}/state # Retrieve the list of plans, in a light representation Source: https://developers.kardinal.ai/api-reference/plan/retrieve-the-list-of-plans-in-a-light-representation /openapi.yaml get /plans At the moment, retrieving a collection of plans is not paginated by default. The paging is performed when at least one of the paging query parameters (`page` or `itemsPerPage`) is present with a valid value. The default values are used like this: - if `page` is present and `itemsPerPage` is absent, the paging is performed with a default value for `itemsPerPage`, - if `itemsPerPage` is present and `page` is absent, the paging is performed with a default value for `page`, - but if both `page` and `itemsPerPage` are absent, no paging is performed. # Stop or restart the optimization of a plan Source: https://developers.kardinal.ai/api-reference/plan/stop-or-restart-the-optimization-of-a-plan /openapi.yaml put /plans/{planId}/running # Update a plan Source: https://developers.kardinal.ai/api-reference/plan/update-a-plan /openapi.yaml put /plans/{planId} # Retrieve a plan solution Source: https://developers.kardinal.ai/api-reference/solution/retrieve-a-plan-solution /openapi.yaml get /plans/{planId}/solution # Retrieve the objectives of a plan solution Source: https://developers.kardinal.ai/api-reference/solution/retrieve-the-objectives-of-a-plan-solution /openapi.yaml get /plans/{planId}/solution/objectives # Create a new webhook on a given plan Source: https://developers.kardinal.ai/api-reference/webhook/create-a-new-webhook-on-a-given-plan /openapi.yaml post /plans/{planId}/webhooks # Delete a single webhook of a given plan Source: https://developers.kardinal.ai/api-reference/webhook/delete-a-single-webhook-of-a-given-plan /openapi.yaml delete /plans/{planId}/webhooks/{webhookId} # Retrieve a single webhook of a given plan Source: https://developers.kardinal.ai/api-reference/webhook/retrieve-a-single-webhook-of-a-given-plan /openapi.yaml get /plans/{planId}/webhooks/{webhookId} # Retrieve the list of results of a webhook of a given plan Source: https://developers.kardinal.ai/api-reference/webhook/retrieve-the-list-of-results-of-a-webhook-of-a-given-plan /openapi.yaml get /plans/{planId}/webhooks/{webhookId}/results # Retrieve the list of webhooks of a given plan Source: https://developers.kardinal.ai/api-reference/webhook/retrieve-the-list-of-webhooks-of-a-given-plan /openapi.yaml get /plans/{planId}/webhooks # Update an existing webhook of a given plan Source: https://developers.kardinal.ai/api-reference/webhook/update-an-existing-webhook-of-a-given-plan /openapi.yaml put /plans/{planId}/webhooks/{webhookId} # Data security and handling Source: https://developers.kardinal.ai/concepts/data-security Hosting and compliance for delivery and end-customer data. Explanation — priority P2. ## To be written * Data location and hosting * GDPR compliance for delivery / end-customer data * Retention period for data sent to the API * Encryption in transit and at rest # Hard vs soft constraints Source: https://developers.kardinal.ai/concepts/hard-vs-soft-constraints Feasibility logic and why a problem can be declared infeasible. Every field in a Kardinal plan falls into one of two categories: constraints the engine **must** respect, and preferences it will respect **if it can**. Knowing which is which explains why a stop sometimes goes unplanned even though "the API didn't return an error." ## Hard constraints A hard constraint rules out any solution that violates it. If the engine cannot find a way to serve a stop without breaking one, that stop is left unserved rather than the constraint being bent. Typical hard constraints in the Kardinal model: * **`authorizedTimeWindows`** — the engine will never plan a stop outside these windows. If none of a stop's authorized windows can be reached, the stop is unserviceable. This is why the documentation recommends making authorized windows as wide as realistically possible: they define the outer bound of what's even considered a valid visit, not a target. * **`requiredSkills`** — a resource must have *every* skill an order requires, or it cannot be assigned to it (e.g. a tailgate-truck requirement). * **Capacities** — a stop's `capacities` must fit within what a resource can carry (and what it has left after prior stops); there is no partial match. * **Order structure** — `successiveStops` (stops of an order must be visited back-to-back), `maxStopSpan` (maximum time between a pickup and its delivery), and `removalStrategy: "lifo"` (a resource can only unload the last thing it loaded) are all structural constraints the engine cannot relax. * **`workingTimeWindow` / `maxWorkingDuration` / `maxDistanceInKm`** on a resource — bounds on when and how much a resource can work, which the engine will not exceed. ### A "contractual" window is not automatically a hard one It's tempting to model a client-facing delivery window as `authorizedTimeWindows` because it's contractual — but "contractual" and "hard" answer two different questions. The question that decides which one to use isn't "is this window written into an agreement?", it's "what should happen if the fleet can't hit it?" * If missing the window is undesirable but not disqualifying — a school delivery running 20 minutes late is still worth making — model it as **`preferredTimeWindows`**. The engine will try to hit it and count any miss as `delay`, but it won't drop the stop just because it can't make the window exactly. * Only use **`authorizedTimeWindows`** when a visit outside the window is truly unserviceable — a site that's physically closed outside those hours, a security checkpoint that won't admit a vehicle early, a customer who will refuse the delivery. **A stated opening/closing hour is necessary, but not sufficient, evidence for "hard."** A data column that gives a site's opening and closing hours only tells you the site *has* hours — it doesn't by itself tell you what happens if a vehicle arrives outside them. That's a separate fact: does the site actually turn a late-arriving vehicle away (or refuse to let it start early), or does it simply prefer being served within those hours while still accepting a late visit? The first case is genuinely `authorizedTimeWindows`; the second is `preferredTimeWindows` built from the exact same hours. Don't infer which one applies from the mere presence of an opening-hours column, and don't treat "the site has hours" and "the site enforces those hours as a hard cutoff" as the same claim — they aren't. If nothing in the data or the client context states the actual dispatch tolerance (an explicit penalty for a late arrival, a fact like "closes and locks the gate", a confirmed refusal policy), that tolerance is exactly the kind of open question this page already asks you to check ("what should happen if the fleet can't hit it?") — treat it as unresolved and default to `preferredTimeWindows` rather than assuming hard. Getting this wrong in the hard direction has a silent, expensive failure mode: every stop whose contractual window can't be reached exactly is dropped and reported as an `unaffectedStopIds` entry instead of being delivered late. If most or all of your delivery windows come from a contract or SLA, default to `preferredTimeWindows` and confirm with the business which windows, if any, are truly non-negotiable — don't assume "contractual" implies "hard." See [Handling infeasibility](/guides/handling-infeasibility) for how to recognize this failure pattern once it happens. ## Soft constraints A soft constraint has a cost, not a wall. The engine will try to satisfy it, but will accept a violation if that's what it takes to do better on a higher-ranked objective. * **`preferredTimeWindows`** — if a stop can't be reached inside its preferred window, it isn't dropped: the gap between the planned time and the window is counted as **delay**, which the `minimizeDelay` objective then tries to reduce. The engine automatically intersects authorized and preferred windows, so a preferred window is only ever a tighter target inside an authorized one, never a way to widen it. * **`lateDeparture: false`** (the default) — the engine prefers starting tasks as early as possible, which can create idle time between non-contiguous windows; this is a scheduling preference, not a rule. * **`priority`** on resources and orders — priority is a soft, graduated dial rather than a binary include/exclude flag. Lower numbers matter more (priority `0` outranks priority `3`); the engine will drop any number of lower-priority resources or orders to protect higher-priority ones, but nothing is hard-coded as "always excluded." Marking an order `"optional": true` is a simpler, coarser version of the same idea. ## How the engine arbitrates conflicts Two mechanisms do the arbitration, and both come from [how the objectives are ordered](/concepts/how-the-optimization-engine-works): 1. **Lexicographic objectives** decide what "better" means when trade-offs are unavoidable — for instance, whether protecting mandatory stops matters more than minimizing delay, or the reverse, depending on how you've ordered your `objectives` list. 2. **Priority elimination** decides *who* gets sacrificed first when something has to give — the lowest-priority resources or orders absorb the impact before higher-priority ones are touched. ## Why a stop, not always the problem, becomes infeasible Kardinal's model rarely declares an entire plan infeasible as a single event. Instead, the engine returns a solution that is valid for everything it *could* place, and reports the rest explicitly: * **`unaffectedStopIds`** — stops that could not be planned within the hard constraints (no authorized time window reachable, no resource with matching skills/capacity/availability, etc.). * **`unusedResourceIds`** — resources that ended up with nothing assigned to them, generally because `minimizeResources` outranks using them, or because none of the remaining orders match their constraints. * **`tours[].isValid`** — a per-resource flag reflecting whether that specific tour respects all hard constraints. In other words, "infeasible" in Kardinal is usually a property of a specific stop or resource, not a rejection of the whole request — the API will still return `200` with a solution, just one where some stops or resources are left out. Diagnosing *which* hard constraint caused a given stop to end up in `unaffectedStopIds` is covered in the [Handling infeasibility](/guides/handling-infeasibility) how-to guide. # How the optimization engine works Source: https://developers.kardinal.ai/concepts/how-the-optimization-engine-works What is minimized/maximized, and the trade-off between solution quality and computation time. Kardinal's engine (ARO — Always-on Route Optimization) is built around two steps: you submit a **plan** describing resources, orders, and constraints, and the engine continuously searches for a **solution**. Understanding how that search behaves — not just the request/response shapes — is what lets you tune it for your business instead of treating it as a black box. ## What the engine optimizes Optimization is driven by an ordered list of **objectives**. The engine processes them lexicographically: it fully prioritizes improving the first objective before considering the second, the second before the third, and so on. Order reflects business priority, not just a weighted average. The default sequence covers most cases (99% according to Kardinal): ```json theme={null} "objectives": [ "maximizeMandatoryStops", "minimizeDelay", "minimizeCosts", "minimizeResources", "minimizeWorkingDuration", "minimizeDistance" ] ``` In plain terms, this default says: first, plan as many mandatory stops as possible; among solutions that do, prefer the ones with less customer delay; among those, prefer lower cost; then fewer vehicles; then less total working time; then less distance. `minimizeCosts` is a no-op unless at least one resource declares a `cost` object (per km, per capacity unit, fixed cost — see [Data model](/reference/data-model#resource-vehicle-driver-pair)). Including it in your `objectives` list without configuring any resource's `cost` doesn't error, it just has nothing to optimize — the objective silently falls through to the next one in the list. If no resource in your plan declares a `cost` object, omit `minimizeCosts` from the list entirely rather than keeping a guaranteed no-op entry. Reordering the list changes the trade-off, not just the score. For example, putting `minimizeResources` before `minimizeDelay` tells the engine that fleet size matters more than on-time delivery — it will happily let stops run late to avoid dispatching an extra vehicle. Putting `maximizeMandatoryStops` first (the recommended default) means the engine will never leave a mandatory stop unplanned just to save a vehicle or some kilometers. This isn't limited to the two objectives called out above — every adjacent pair in the list carries the same kind of trade-off, including `minimizeCosts` vs. `minimizeResources`: placing `minimizeCosts` first prioritizes total cost even if reaching it takes an extra vehicle, while placing `minimizeResources` first caps fleet size first and lets cost settle wherever that leaves it. Neither position is a safe, order-independent default — the two are not interchangeable, and swapping them changes which solutions the engine considers better. Decide the position of every objective in your list deliberately, based on which trade-off matters more for the operation you're modeling, rather than assuming any pair not spelled out by name in this page is order-insensitive. Treat the default sequence above as a starting point to re-check against your specific plan's constraint structure, not a template to copy unconditionally — a default built for the general case can include an objective that's a no-op for your case, or omit one that matters for it. For example, check whether every window in your plan is a hard `authorizedTimeWindows` constraint: if so, there's no "delay" left to minimize, and `minimizeDelay` has nothing to do (see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints)). Re-derive the list from what your plan's own constraints actually look like, rather than reusing a default sequence unchanged because it "usually works." Other available objectives include `minimizeDistance`, `minimizeWorkingDuration`, `maximizeOptionalStops` (for stops marked `"optional": true`), `maximizePreferredStops` (favors assigning tagged stops to resources with matching `preferredStopTags`), `minimizeLargestTourDuration` (caps how unbalanced the longest single tour can get relative to the rest of the fleet, rather than only minimizing the total across all tours), and custom cost-based objectives for advanced pricing models (overtime, per-stop-type costs, etc.). ## The quality vs. computation time trade-off There is no single "optimize until done" call — instead, you control how much time the engine is allowed to spend via `maxOptimizationDuration` (an ISO 8601 duration, e.g. `"PT10M"`): * The engine is guaranteed to never regress: each new solution it publishes is at least as good as the previous one on the objective sequence above. There's no risk of "rolling back" to something worse. * The longer you let it run, the better the solution can get — but returns diminish. If the engine hasn't found an improvement in a while, it considers itself done, even before `maxOptimizationDuration` elapses. * `maxOptimizationDuration` only counts time actually spent searching. It excludes queueing time (if no worker is free), and the time spent building the underlying math problem (fetching travel times, etc.). Add a margin before you fetch a solution to account for this. This gives you three practical integration patterns: Set a long `maxOptimizationDuration` (e.g. `PT30M`), submit the plan, and fetch the solution after that window (plus margin). Simple, but you wait for the full window even if the engine converged early. Use a short `maxOptimizationDuration` and, if the solution isn't good enough, restart optimization with `PUT /plans/{planId}/running` (body `true`). Updating the plan has the same restarting effect. Restarting won't help if the duration is too short for the problem size, or if the engine already considers the current solution final. Poll the plan's `status` field and the solution's objective values, and decide for yourself when the result is "good enough" for your business — without waiting for the engine to fully settle. This is the most responsive pattern and the one Kardinal recommends. The `status` field tracks a plan through its lifecycle — waiting room (if you're over your max simultaneous running plans quota), creation (fetching travel times, building the problem), optimization, and, if predictive traffic is enabled, an asynchronous traffic-fetching stage running in parallel: ```json theme={null} "status": { "waitingRoom": { "waitingVersion": 1, "runningVersion": 1 }, "creation": { "waitingVersion": 1, "runningVersion": 1 }, "optimization": { "waitingVersion": 1, "runningVersion": 1 }, "waitingTraffic": { "waitingVersion": 1, "runningVersion": 1 } } ``` ## Continuous and interactive optimization Submitting a plan again with the **same `id`** doesn't start a new problem from scratch — it tells the engine "this is the same problem, here's what changed." The version number increments automatically, and the engine degrades the previous solution just enough to remain valid for the new data, then keeps improving from there. This first optimization on a given plan layout is slower (the engine is learning the problem's shape); subsequent updates are typically much faster. Because of this, if you update a plan while an older version is still optimizing, the engine will not let two versions be considered "optimized" at the same time — it stops work on the stale version in favor of the newest one. ## What you control vs. what the engine controls You control: the objectives list and its order, `maxOptimizationDuration`, when to restart or stop optimization (`running: false`), resource/order `priority`, and `lateDeparture` (whether the engine compacts routes to minimize idle time or starts tasks as early as possible). The engine controls: the actual search strategy, and the guarantee that solution quality never regresses between versions. Problem complexity — and therefore how much `maxOptimizationDuration` you should budget — is mostly driven by the number of stops and resources, whether traffic-aware vehicle profiles (`withTraffic: true`) are used, and whether advanced constraints (alternative stops, LIFO removal strategy, overlapping-capacity limits) are in play; some of these switch the engine to alternative algorithms that are markedly slower. Event-driven notifications (event bus / webhooks) so you don't have to poll are not implemented yet — contact Kardinal if this is a requirement for your integration. See [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) for how the engine decides what's non-negotiable versus what it can compromise on while searching for these objectives. # Sync/async architecture Source: https://developers.kardinal.ai/concepts/sync-async-architecture What determines response time depending on problem size. Explanation — priority P2. ## To be written * When the API responds synchronously vs asynchronously * Factors that determine computation time * How to track the progress of an async calculation # Start here if you're an AI agent Source: https://developers.kardinal.ai/getting-started/agent-modeling-checklist Mandatory checklist for any AI agent modeling a client's logistics problem as a Kardinal plan — read this before writing any integration code. This page is written for an **AI agent** building a Kardinal integration from a client's raw data (a spreadsheet, a CRM export, a verbal description of their operation) — not for a human developer exploring the API. If you are an agent doing this kind of work, read this in full before writing a single line of code or calling any endpoint. If you are human, this is still useful context, but [First API call](/getting-started/first-api-call) is the better starting point. Every point below comes from observed failure modes, not hypothetical ones — each one caused a real, measurable gap between an agent's payload and a correct reference plan for the same client data. ## 1. Don't silently default on an irreducible ambiguity — ask Some questions genuinely cannot be answered from the API documentation or from the client's raw data, because the answer depends on operational tolerances only the client knows (how bad is a late delivery, really? is a truck legally required for this route?). When you hit one: * Do **not** pick a default and move on silently. * Do produce an explicit, visible list of open questions requiring client confirmation, separate from the rest of your modeling notes — one item per ambiguity, stating what you assumed in the meantime and why. * This includes binary policy decisions — a boolean or a choice between two options that isn't derivable from the data (for example, whether resources are allowed to compact idle time instead of always departing as early as possible). A two-way choice is still an open question: it belongs on this same visible list, not as a footnote tucked away among other modeling notes elsewhere in your report, where it's easy for a reviewer to miss. * List every field you chose not to set at all, not only the ones where you inferred a risky value. A field with zero trace in the source data still belongs on the list ("not set — no source data for this") — a field that's simply absent from your report, with no mention anywhere, is indistinguishable to a reviewer from an oversight you didn't notice. * Don't rely on remembering which optional fields matter from context — that's exactly the habit that lets one get silently dropped. Instead, before finalizing your report, run through the full set of optional/placeholder-prone fields on every object type you populated (resource-level fields like `agencyId`, `maxWorkingDuration`, `breaks`, `maxDistanceInKm`; stop-level fields like `departure`/`arrival`, `lateDeparture`, secondary `capacities` dimensions beyond the primary one) and state the status of each explicitly — set from data, defaulted, or not set — rather than only mentioning the subset you happened to think of unprompted. A field you correctly handled last time is not evidence you'll remember to check a *different* field this time; treat this as a fixed pass over the whole set, every time, not a memory exercise. * A prose reminder to "state the status of each field" has repeatedly failed to survive contact with an actual delivery, even when the exact field name involved was already spelled out above, in this exact document, ahead of time — reading the rule is not the same as applying it under the pressure of finishing a deliverable. Don't rely on prose alone a second time: deliver the field-status pass above as a literal table (one row per field in the list, columns for field name / status / value-or-reason), inline in your modeling report, not as a paragraph summarizing the outcome. A table has no room for a field to be silently absent the way a sentence does — an omitted row is visibly a blank spot in a structure the reviewer can check row-by-row, where an omitted clause in a paragraph is not. If your delivery format can't render a table, use an equivalent fixed key: value list with one line per field, in the same fixed order every time — the point is a structure a reviewer (or a script) can diff against the full field list, not free-form prose that happens to mention most of it. * Resolving a structural decision with confidence in your own arithmetic is not the same as confidence in the assumption that arithmetic depends on. If a structural choice (for example, how many loops a route needs, or which pattern applies) follows deterministically from a value you yourself estimated because the real one was missing from the data (a fleet size, a demand figure, a headcount), state the choice as contingent on that estimate — "we chose X because we sized Y ourselves; if the real Y turns out to differ, this choice would flip" — rather than presenting the downstream decision as settled just because the math that produced it was done correctly. * When two fields are documented as a linked pair — one only makes sense in light of the other, for example a wide working-time window combined with a duration cap, where the two together determine what the payload actually means — don't address one half while silently leaving the other unaddressed. If you set or explicitly flag one half of such a pair, state the status of the other half too, even if that status is "not set" or "left at default," and note the relationship between the two if it isn't obvious from the field names alone. Leaving one half silently unaddressed doesn't fall back to some neutral default meaning — it produces a third, different, unflagged meaning that no one asked for and that no reviewer was ever told to expect. A payload that is 100% internally consistent but built on unstated, unconfirmed business assumptions is not a safe deliverable — it's a set of guesses with good formatting. The sections below are specific, recurring cases of this same principle. ## 2. Check capacity feasibility before modeling orders — and don't resolve a shortfall on your own Before you settle on the aggregation level used to group source rows into an `Order` (for example, a delivery-note ID, an order/consignment number, or some other row-level key), check the relationship between that level and its neighbors in **both** directions: verify that no group at your chosen level mixes rows that belong to different underlying entities (a many-to-one check against the next *finer* level), and also check whether a single customer or site has more than one occurrence of your chosen level on the same day — something a real operation might consolidate into a single visit (the many-to-one check against the next *coarser* level, such as the customer or site itself). Passing only the first check doesn't prove your chosen level is the one real operations actually uses: there can be a coarser level that production consolidates onto, with nothing in the source data explicitly flagging the gap. Even after this two-direction check passes, cross-check the resulting grouping against any independent referential already present in the source data — for example, a canonical driver-vehicle pairing table — before treating a plausible-looking grouping as final: a group that looks structurally consistent can still combine rows that the client's own referential data says shouldn't go together. Also scan free-text note or comment fields for explicit consolidation hints (a note stating a delivery is "same as" or "same zone as" another) — this is exactly the kind of signal a purely structural or positional check will miss, since it lives in prose rather than in the fields you're joining on. Before finalizing how you model orders, add up the total demand (weight, volume, or whatever capacity unit the client uses) across all stops for a given day, and compare it against the fleet's total capacity in a single loop (sum of all resources' `capacities`). * If total demand **fits** within total fleet capacity in one loop: model each delivery as a single `delivery` stop. No pickup counterpart needed. * If total demand **exceeds** total fleet capacity in one loop: a single-loop model is not just suboptimal, it's infeasible — a real fraction of stops will land in `unaffectedStopIds` regardless of how the rest of the plan is tuned. But don't resolve this on your own by defaulting to the multi-trip pattern (or any other fix). A capacity shortfall is an operational question, not something you can decide from the data alone: it might mean the fleet genuinely reloads at a depot mid-shift (in which case [Multi-trip tours](/guides/multi-trip-tours#the-core-mechanic-pair-each-delivery-with-its-own-depot-pickup) is the right mechanism), or it might mean the client needs additional vehicles, a different schedule, or a split across multiple days. Surface the shortfall explicitly as an open question requiring client confirmation (see §1) — state the numbers (total demand vs. total capacity) and the possible resolutions, rather than picking one silently. The aggregate check above is necessary but not sufficient — also run it **per order**: compare each individual order's own demand against the *largest single* vehicle capacity available in the fleet (not the fleet's total). An order whose demand exceeds every single vehicle's capacity cannot be assigned as one stop under any fleet composition — the aggregate check alone won't catch this, since a fleet with ample total capacity can still contain no single vehicle large enough for one oversized order. If you find one, split that order into multiple pickup+delivery legs (or otherwise decompose it) before finalizing the payload, rather than shipping it as a single stop and letting the solver silently drop it into `unaffectedStopIds`. Unlike the aggregate shortfall above, this check and its fix are fully derivable from data you already have (the order's own demand, the fleet's per-vehicle capacities) — it doesn't require client confirmation, only that you actually run the comparison. Do this arithmetic explicitly and state the result in your modeling notes, even when the answer is "fits comfortably, nothing to flag here." This exception requires *independent* evidence, from the client's own data or stated context, that the reload pattern is the intended operation (for example, it explicitly describes a depot with reload capacity or dock availability) — not merely a shortfall that happens to appear after you yourself estimated a missing figure (like fleet size) in the absence of real data. A shortfall you created via your own upstream assumption does not satisfy this precondition, no matter how well the resulting pattern happens to match reality — check that the depot/reload evidence exists in the source data on its own terms, independent of any number you had to guess to get there, before invoking this exception. If the client's own data or stated context makes the reload pattern clearly the intended operation, you may model the multi-trip pattern and note it as a confirmed assumption rather than an open question — but the default when this isn't stated is to ask, not to choose between single-loop and multi-trip yourself. That independence check is necessary but not sufficient on its own: also confirm that the evidence itself has discriminating power. A flag or column cited as proof that an exception applies to a specific site, day, or subset must actually *vary* across the relevant data — a column that holds the exact same value on every row of the **entire** dataset, not just the filtered subset you're looking at, cannot possibly indicate anything site- or day-specific, no matter how directly its name seems to match the question. Before citing any such flag as justification, check its distribution across the full dataset, not only within the rows you've already filtered down to — a column that's constant fleet-wide has zero discriminating power, however plausible its name sounds for your specific case. Before you let any aggregated total computed from the source data drive this decision, verify the actual semantics of every source column that feeds into it — check a handful of concrete rows and confirm whether a value scales with quantity the way a per-unit rate would, or whether it's already an aggregated total. Don't trust the column name alone. A unit mismatch or an accidental double-count can inflate a computed total by an order of magnitude, and a total that far off can itself trigger a structural pattern (for example, multi-trip) that the real data never called for — after which every decision that cascades from it (per-resource capacity, the reload pattern, etc.) will be wrong too. ## 3. Treat "contractual" and "hard constraint" as two different things A time window described as contractual, SLA-based, or client-facing is **not automatically** a hard constraint. Read [Hard vs soft constraints](/concepts/hard-vs-soft-constraints#a-contractual-window-is-not-automatically-a-hard-one) before deciding between `authorizedTimeWindows` and `preferredTimeWindows` for any window in your dataset. If the source data doesn't explicitly say whether missing a window means the stop becomes unserviceable (hard) or merely undesirable but still worth serving late (soft), don't guess — this is a business decision, not a modeling detail. Default to `preferredTimeWindows` (the safer failure mode: a late stop still gets served) and list it explicitly as an open question for the client to confirm (see §1). This default applies only when the data is genuinely silent — check for a positive signal before falling back to it. A column that literally states a site's open/closed status, or any other explicit flag distinguishing "unserviceable outside this window" from "preferred, but still workable late," is exactly the kind of stated fact the hard-vs-soft page's own physically-closed-site example is describing — not an unstated case. When such a signal is present, it takes precedence over the soft default above: model it as `authorizedTimeWindows` (treating the signal as a stated hard constraint, not an assumption you invented) rather than defaulting to soft because the situation wasn't phrased in exactly the words this checklist uses. The soft default exists for the case where no such signal exists at all, not as a fallback that overrides one once you've found it. A generic "contractual doesn't automatically mean hard" caution, on its own, is not a reason to override a positive site-level signal once you've found one. That caution exists to stop you from *inventing* hardness that isn't stated anywhere — it has no work left to do once a literal status flag is already present in the data. If you find yourself reasoning from the general caution *instead of* engaging with a specific signal you already identified, that's a sign you're applying the caution outside the case it's for, not a sign the signal doesn't count. ## 4. Don't guess on vehicle profile, break triggers, or other underspecified fields — check what the data actually supports Some fields have more than one plausible value and no way to derive the "right" one from the API's semantics alone: * **`vehicleProfile.type`** (`car` vs. `truck`, or any other vehicle-type/mode inference): if the source data doesn't specify vehicle dimensions or gross weight, and doesn't state a legal reason to require truck-specific routing, don't assume one or the other from the vehicle's commercial name alone — check [Data model](/reference/data-model#vehicle-profiles) for what each profile actually changes, and flag the choice as an assumption if the docs don't state a clear default for your case. The same rigor applies with at least as much force when a name or free-text label suggests a more exotic mode entirely (a bicycle, a motorcycle, and so on) rather than a choice between two similar road-vehicle profiles — inferring an exotic mode from a commercial name changes the routing mode/network even more drastically than car-vs-truck does, so it deserves *at least* as much scrutiny, never less, just because the name happens to look more specific or colorful. And if the source data contains a verified field that contradicts the name-based inference — for example, a declared capacity or gross weight that's implausible for the literal vehicle type the name suggests — the verified field must override the name-based guess. Noting the contradiction as a caveat in your report while still shipping the name-based classification does not satisfy this rule; it's the same disclosing-≠-complying failure as shipping a fabricated position while flagging it (see §5) — surfacing a conflict is not a substitute for resolving it in favor of the verified data. * **`breaks[]` trigger type**: a source dataset that gives you a break *duration* (e.g. "30 minutes") without a triggering rule (a fixed time window, a cumulative working-time threshold, or a cumulative driving-time threshold) does not contain enough information to pick one of the three `breaks[]` types correctly. Don't invent an arbitrary or unverifiable regulatory regime — but that's a different case from applying a well-known, *public* regulatory default whose jurisdiction is unambiguous from data you already have (for example, a vehicle type together with a plan-level `tz` that together leave no real doubt about which country's transport-sector break rules apply). In that narrower case, applying the well-known default is the **required action**, not merely a preference over shipping zero breaks: once the jurisdiction is genuinely unambiguous from data you already have, shipping zero breaks is a silent, unflagged omission like any other (see §7), not a neutral, safe fallback. Still list the applied default explicitly as an open question for client confirmation (see §1) rather than applying it silently — this doesn't relax to a suggestion just because you're required to act on it. A jurisdiction's public default is frequently made of more than one concurrent rule rather than a single one — for example, French road-transport law imposes both a `workingDurationSlidingBreak` (a break after a cumulative amount of *work*) and a `travelDurationSlidingBreak` (a break after a cumulative amount of *driving*) at the same time, as two independent, non-substitutable entries in `breaks[]`. Applying only one of several rules a jurisdiction actually requires is a partial fix, not a complete one — it still leaves the same category of gap, just smaller and easier to overlook. The distinction that matters is *arbitrary/unverifiable* versus *public and jurisdiction-obvious*, not whether some default rule exists at all — only when you genuinely cannot establish the jurisdiction from data you already have does flagging-without-acting remain the right call. Not every rule in a jurisdiction's regulatory default carries the same confidence, though, and the duration figure is exactly where that distinction matters most: a rule set by public, jurisdiction-wide statute (independent of any single employer) is safe to apply with its exact statutory figure, while a rule that's typically layered on top by a sector- or company-level collective agreement is not — a single number there is likely to be wrong for a specific client even when the *type* of break is obvious. For the EU/French road-transport `travelDurationSlidingBreak` case above, the driving-time break is public statute (EU Regulation 561/2006): `minBreakDuration: "PT45M"` after `maxInterBreakDuration: "PT4H30M"` of cumulative driving is safe to apply outright once the jurisdiction is established, same as any other required default in this section. Don't extend that same confidence to the *working-time* rule's duration in the same jurisdiction: its specific minute value is far more likely to be set by a collective agreement than by the statute alone, and unlike the driving-time case, there's no generic figure the checklist can safely hand you — picking a plausible-looking number would be guessing at a fact this document has no way to know. `minBreakDuration`/`maxInterBreakDuration` aren't optional on a `breaks[]` entry, so there's no way to "include the rule without its number": flagging that a `workingDurationSlidingBreak` entry is legally required is still the required action once the jurisdiction is obvious, but omit the entry itself from `breaks[]` rather than fabricate its duration to make one valid — state the omission explicitly in your open questions (see §1), alongside the break type and cadence it will need once the client confirms the figure, next to the driving-time entry you do default and ship. * **Objectives list, when the fleet composition is fixed rather than left free**: if your `resources` list is built by reproducing an existing fleet or a set of historical tours (rather than left open for the solver to size on its own), don't just copy a default objectives list — explicitly check the full objectives catalog (see [How the optimization engine works](/concepts/how-the-optimization-engine-works)) for a tour-balancing objective (for example, one that caps how long the single longest tour can get relative to the rest of the fleet) and consider whether it applies. A fleet fixed from historical data is exactly the situation where individual tours can end up uneven — the scenario this kind of objective exists for — so re-check against the complete list rather than reusing whatever objectives a previous case happened to use. ## 5. Verify you have real geocoded positions before finalizing Kardinal does not geocode addresses — every `position` you submit (resources' `departure`/`arrival`, and every stop's `position`) must already be a real `{lat, lon}` pair, precise enough to reflect the actual site. If the source data only gives you postal addresses, don't fall back to an approximation (a town/commune centroid, a jittered placeholder) and consider the payload done: it will run and look structurally valid, but its results (distances, sequencing, time estimates) become meaningless, and stops in the same town collapse to the same point. If you don't have a way to geocode the addresses in your current environment, say so explicitly in your data-quality report (see §7) rather than silently shipping placeholder coordinates — a payload built on approximate positions looks complete while being unusable for a real optimization run. Disclosing a substitute position as an open question does **not** satisfy this rule. Listing a fabricated coordinate (a statistical barycenter, a named landmark, a jittered point) in your open-questions list is not an alternative way to comply — it's the same violation with a caveat attached, and it's still built on a position that isn't real. The required action for a missing or unreliable position is to leave the field unset (or otherwise not submit a concrete position for it) and flag that explicitly — never to ship any placeholder value alongside the disclosure, however prominently you disclose it. This "say so, don't ship a placeholder" instruction **takes precedence over** any general instruction to deliver a complete, immediately runnable payload — for this field specifically. If the two seem to conflict, the narrower, more specific stop-condition wins: an incomplete payload that honestly flags missing real positions is the correct deliverable here, not a complete-looking payload built on fabricated coordinates. This precedence rule isn't specific to geocoding — wherever this checklist gives an explicit "stop and flag it" rule for a particular field, that rule overrides the general goal of payload completeness for that field, even when nothing in your task instructions explicitly carves out the exception. ## 6. Use the SDK's typed objects when available; otherwise, validate against the data model field by field If a Kardinal SDK is available for your target language, build `Resource`/`Order`/`Stop` objects through it rather than hand-writing a JSON dictionary — the SDK's own docstrings carry modeling guidance at the exact point you need it, and it removes a class of structural mistakes entirely. If you're constructing the request body directly, cross-check every field you set against [Data model](/reference/data-model) before submitting — don't rely on memory or on a single worked example to infer field names and types for a case that example didn't cover. ## 7. Always deliver a modeling report and a data-quality report alongside the payload A payload that "looks right" is not a safe deliverable on its own — whoever reviews your work (the client, or a colleague) needs to see what you decided and why, and needs to know if the source data itself has problems that limit what any agent or the API can do with it. Alongside the payload, always produce: * **A modeling report**: every non-trivial decision you made — unit choices, field mappings, hard vs. soft trade-offs, assumptions about fleet or vehicle behavior — with a short justification each. This is broader than the open-questions list in §1: it covers the full set of choices someone would need in order to sanity-check the payload against their actual operation, not only the ones you were unsure about. This applies as a general self-check procedure to *every* claim you make about something your own code did, not just to the specific case that happens to be spelled out here: before writing a sentence like "this validates," "this succeeds," or "this check confirms X," grep your own delivered script for the exact call or logic you're describing, and quote the line number or a snippet as evidence. This is not a rule about validation-and-round-trip claims specifically (for example, re-parsing your own output through the SDK's typed models to confirm it validates) — it's a rule about any claim of this shape, on any topic, in any report you deliver. If you can't point to the specific line that performs the claimed step, don't make the claim, regardless of whether you recall running it in an earlier or interactive version of the script that isn't part of what you're shipping. * **A data-quality report**: anything in the source data that blocks finalizing the modeling, or would produce an incorrect or incomplete result if silently worked around — missing values on fields you need, inconsistent codes (e.g. two spellings of the same location, a field that mixes two different coding schemes), contradictory rows, or values outside a plausible range. Don't fix a data problem quietly and leave a one-line code comment as the only trace — list it explicitly so the client can correct it before the payload is used for anything real. The same traceability rule as above applies to any count or list you quote here (for example, "N addresses had low-confidence geocoding," or "M rows had a missing field"): if your own script computes it, that number must be the script's literal, reproducible output — copy it verbatim — not a hand-summarized approximation, a rounded-down estimate, or a partial list that quietly drops the worst instance. A summary statistic you can't reproduce by re-running your own delivered script is the same unbacked-claim problem as an unverifiable validation claim, just applied to a number instead of a pass/fail. A prose reminder to check your own delivered code before writing a verification or validation claim has repeatedly failed to survive contact with an actual delivery — this exact failure mode has now recurred across multiple independent studies, on different clients, in completely different business domains, and with different models each time, despite the rule already existing in prose immediately above. Don't rely on prose alone a second time: whenever your modeling report lists verification or validation claims (any check that "confirms," "validates," or "ensures" something about the delivered payload or code), deliver them as a literal table — one row per claim, with columns for the claim itself and the exact file, line number, or function that implements it — not as prose sentences interspersed with your analysis. If your delivery format can't render a table, use an equivalent fixed list of `claim → file:line/function` entries, one per line, in a consistent order. A table (or fixed list) has no room for a claim to appear without a matching implementation reference the way a paragraph does: a blank or missing cell is visible to a reviewer scanning row by row, where a plausible-sounding but unbacked claim buried in prose is not. The traceability rule above is stated in terms of validation claims and summary statistics because those are the cases that have come up so far — but the underlying principle is broader than either: **don't assert anything, in any report, about a piece of state you have not checked at the moment you write the sentence.** Two further cases of this same failure are common enough to call out by name, so that the rule doesn't quietly narrow back down to only the cases already named: * **File-delivery claims.** If your report says a file is included "for traceability," "for reproducibility," or any similar claim of presence, that file must actually exist in the folder you deliver — check with a literal directory listing right before you write the claim, not from memory of having created it earlier in your session. A claim that a file exists when it doesn't is exactly as unbacked as a validation claim with no matching code, even if every number that file *would have* contained is itself accurate and reproducible elsewhere. * **Claims about the current state of Kardinal's own documentation or API.** If your report characterizes what the docs or spec currently say (for example, that a field's description is still a placeholder, or that a page states a particular default), that claim must come from reading the live file in front of you at the time you write it — not from a prior session, a cached impression, or something you recall reading in a different context. Documentation changes between sessions; a claim about its current content is a claim about state exactly like a validation claim is, and needs the same fresh, direct check before you write it down. Without these two reports, no one downstream can tell an informed decision from a guess, or a clean dataset from one with problems baked in. ## 8. Derive geographic scope from an authoritative reference table, not a postal-code heuristic, when one exists When a task asks you to scope or filter entities (customers, sites, orders) to a specific geographic region — a metro area, a delivery zone, a sales territory — don't reach for a postal-code-prefix heuristic (matching codes that start with a given digit sequence) as your only method. Check first whether the source data already contains an authoritative reference table with real coordinates for the relevant entities (a depot, agency, or site table, for instance). If one exists, prefer deriving region membership from that table — for example, assigning each entity to its nearest depot/agency by actual distance — over a postal-code-prefix match. A postal-code prefix is an administrative proxy for geography, not geography itself: postal boundaries don't reliably align with operational or metro-area boundaries, and a prefix match will silently misclassify boundary cases — an entity whose postal code happens to fall just outside (or inside) your chosen prefix range even though it's operationally served by, or physically closest to, a site inside (or outside) your intended scope. This exact mistake has recurred across more than one client dataset, with different agents making it independently on different runs, and in every case the correct classification was recoverable purely from data already in hand (real coordinates on an existing reference table) rather than from anything requiring new client input — so this is an avoidable execution gap, not an irreducible client-only ambiguity to flag and move past. Apply this generically, regardless of client or dataset: whenever a depot/agency/site reference table with coordinates exists in the source data, use nearest-entity matching (or another distance-based method against that table) as the primary way to determine region membership, and treat a postal-code prefix as a fallback only for the entities or datasets where no such coordinate-bearing reference table exists at all. A related check applies even earlier, before you reach the scoping step above: when a task hands you a client-supplied perimeter or scope code (a site code, a zone code, a region code) to filter on, check whether that exact code string is reused at a **different level of granularity** elsewhere in the source data — for example, an operational code that identifies one specific site in the sheets that actually build your resources and orders, but that happens to share its exact value with a broader administrative or regional grouping label in a separate, purely descriptive sheet. When the columns that actually construct your resources and orders use the code at one specific granularity consistently, prefer that operational-level reading over a broader one found only in a descriptive or administrative sheet — the sheets doing the real construction work are the stronger signal. If there's genuine ambiguity about which granularity was intended, don't resolve it silently: surface it as an open question requiring client confirmation (see §1), the same as any other irreducible ambiguity. ## See also * [Data model](/reference/data-model) — the full field-by-field reference. * [Multi-trip tours](/guides/multi-trip-tours) — depot-return / reload pattern in detail. * [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) — the full decision framework, not just the contractual-window case above. * [Complete route walkthrough](/getting-started/complete-tour-walkthrough) — a full worked example from minimal plan to multi-constraint plan. # Complete route walkthrough Source: https://developers.kardinal.ai/getting-started/complete-tour-walkthrough Model a representative case: multiple vehicles, depots, and time windows, through to a usable result. This walkthrough builds a more realistic plan than [First API call](/getting-started/first-api-call): two vehicles starting from two different depots, delivering to stops with time windows. It assumes you've already authenticated — see [Authentication](/guides/authentication) if you haven't. ## The scenario Two vans, each starting and ending its day at a different depot, deliver to four stops with client-facing delivery windows. ## Step 1 — start minimal: resources and orders only Get a feasible plan running before adding any constraints. Each resource needs at minimum an `id`, a `vehicleProfile`, and a `workingTimeWindow`; each order needs an `id` and at least one `stop` with a `position`. ```json theme={null} { "id": "walkthrough-plan", "resources": [ { "id": "van-north", "vehicleProfile": { "type": "car" }, "workingTimeWindow": { "begin": "2026-08-03T07:00:00Z", "end": "2026-08-03T16:00:00Z" }, "departure": { "lat": 48.8738, "lon": 2.2950 }, "arrival": { "lat": 48.8738, "lon": 2.2950 } }, { "id": "van-south", "vehicleProfile": { "type": "car" }, "workingTimeWindow": { "begin": "2026-08-03T07:00:00Z", "end": "2026-08-03T16:00:00Z" }, "departure": { "lat": 48.8228, "lon": 2.3510 }, "arrival": { "lat": 48.8228, "lon": 2.3510 } } ], "orders": [ { "id": "order-1", "stops": [{ "type": "single", "id": "stop-1", "position": { "lat": 48.8656, "lon": 2.3212 }, "kind": "delivery", "operationDuration": "PT10M" }] }, { "id": "order-2", "stops": [{ "type": "single", "id": "stop-2", "position": { "lat": 48.8462, "lon": 2.3372 }, "kind": "delivery", "operationDuration": "PT10M" }] }, { "id": "order-3", "stops": [{ "type": "single", "id": "stop-3", "position": { "lat": 48.8330, "lon": 2.3708 }, "kind": "delivery", "operationDuration": "PT10M" }] }, { "id": "order-4", "stops": [{ "type": "single", "id": "stop-4", "position": { "lat": 48.8580, "lon": 2.2945 }, "kind": "delivery", "operationDuration": "PT10M" }] } ] } ``` Submit this the same way as in [First API call](/getting-started/first-api-call) (`PUT .../plans/{planId}`) and confirm two tours come back in the solution, together covering all four stops. Positions here are illustrative — see [Data model](/reference/data-model#order-stop) for why real positions must be pre-geocoded before you submit them. ## Step 2 — add depots as `departure`/`arrival` The plan above already has each van start and end at a depot position rather than at the first/last stop — this is what makes the two-depot scenario realistic. If `departure`/`arrival` are omitted, the working day starts and ends at whichever stop the engine happens to assign first/last, which is rarely what a dispatcher expects for a fleet with fixed depots. ## Step 3 — add delivery time windows, and choose hard vs soft deliberately Now add a delivery window to each stop. This is the step where it's easy to get the modeling choice wrong: a client-facing or "contractual" window is not automatically a hard constraint. Ask what should happen if the fleet can't hit the window exactly: * If a late (or early) visit is still worth making — the customer would rather get a delayed delivery than none — use **`preferredTimeWindows`**. Missing it costs `delay`, tracked by the `minimizeDelay` objective, but the stop still gets served. * Only use **`authorizedTimeWindows`** if a visit outside the window genuinely can't happen (site closed, access refused). See [Hard vs soft constraints](/concepts/hard-vs-soft-constraints#a-contractual-window-is-not-automatically-a-hard-one) for the full reasoning — the short version is: default to `preferredTimeWindows` for contractual windows unless you've explicitly confirmed otherwise with the business. ```json theme={null} { "type": "single", "id": "stop-1", "position": { "lat": 48.8656, "lon": 2.3212 }, "kind": "delivery", "operationDuration": "PT10M", "preferredTimeWindows": [ { "begin": "2026-08-03T09:00:00Z", "end": "2026-08-03T11:00:00Z" } ] } ``` Add `"minimizeDelay"` to your `objectives` list (it's already in the recommended default — see [How the optimization engine works](/concepts/how-the-optimization-engine-works)) so the engine actually optimizes against these windows rather than treating them as decoration. ## Step 4 — read the result Fetch the solution the same way as in [First API call](/getting-started/first-api-call) and check, per tour: * `tours[].wayPoints` — the assigned stops, in visiting sequence, each with a computed `arrivalTime`. * `tours[].isValid` — whether that specific tour respects all hard constraints; check this per-tour, not just at the plan level. * `unaffectedStopIds` — any stop that couldn't be placed at all. With everything modeled as `preferredTimeWindows` above, this should be empty; if you'd used `authorizedTimeWindows` instead and a window were unreachable, the stop would show up here instead of arriving late. * `tours[].distanceInKm` and `tours[].workingDuration`, useful for a sanity check against what you'd expect for the geography. If a stop you expected to be served ends up in `unaffectedStopIds`, see [Handling infeasibility](/guides/handling-infeasibility) for how to diagnose which constraint caused it. ## Common pitfalls at this stage * **Units.** Distances in the API are kilometers, not miles; durations are ISO 8601 (`PT30M`, not `30`); make sure any values sourced from a spreadsheet are converted before submission. * **Time zones.** Datetimes without an explicit UTC offset are ambiguous — either include the offset on every datetime, or set the plan-level `tz` field once (e.g. `"Europe/Paris"`) and write local, offset-free datetimes. Mixing both styles in the same plan is a common source of off-by-a-few-hours bugs. * **Positions.** `position` fields must already be geocoded latitude/longitude pairs — the API does not geocode addresses for you. See [Data model](/reference/data-model#order-stop) for what to use instead. * **Hard vs soft defaults.** As shown in Step 3, don't default a contractual time window to `authorizedTimeWindows` just because it's contractual — that choice silently drops stops rather than delivering them late. ## See also * [Data model](/reference/data-model) — full field dictionary for everything used above. * [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) — the general reasoning behind the Step 3 choice. * [Handling infeasibility](/guides/handling-infeasibility) — what to do when a stop doesn't get planned. # First API call Source: https://developers.kardinal.ai/getting-started/first-api-call Get a sandbox key and calculate your first route in under 15 minutes. Kardinal's Always-on Route Optimization API (ARO) works around two steps: you submit a **plan** (vehicles, orders, constraints), and you retrieve the **solution** the engine computes for it. This tutorial walks through both, using the smallest possible plan. **AI agent modeling a real client's data, not just running this tutorial?** See [Start here if you're an AI agent](/getting-started/agent-modeling-checklist) first — this tutorial's minimal plan skips several decisions (capacity feasibility, hard vs. soft time windows) that matter as soon as you're working from real data. Access to the API is provisioned by invitation: you're invited to set a password for a username (usually your company email), and given an environment URL that looks like `https://.kardinal.ai` (for example `https://app.kardinal.ai`). There is currently no self-serve sign-up flow — if you don't have credentials yet, contact your Account Executive or [api@kardinal.ai](mailto:api@kardinal.ai). ## Step 1 — Authenticate Kardinal uses JWT authentication. Exchange your username and password for an `access_token`: ```bash theme={null} curl -X POST "https://.kardinal.ai/api/v2/login" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"username":"","password":""}' ``` ```json Response theme={null} { "access_token": "", "refresh_token": "", "user": { ... } } ``` Keep the `access_token` — every following request uses it as a bearer token. It's valid for **one hour**; see [Authentication and API keys](/guides/authentication) for how to refresh it. ## Step 2 — Submit a minimal plan A plan is created with `POST /plans` — the service generates the plan's `id`, returned in the response; a client-supplied `id` in this request is rejected with a `400`. Here is the smallest viable plan: one resource and three pickup-only orders in Paris. ```bash theme={null} curl -X POST "https://.kardinal.ai/api/v2/plans" \ -H "Accept: application/json" \ -H "Authorization: Bearer " \ -d '{ "resources": [ { "id": "resource1", "vehicleProfile": { "type": "fly", "kmph": 20 }, "workingTimeWindow": { "begin": "2023-03-21T08:00:00Z", "end": "2023-03-21T23:00:00Z" } } ], "orders": [ { "id": "order-1", "stops": [{ "type": "single", "id": "Balard", "position": { "lon": 2.279424, "lat": 48.835749 }, "kind": "pickup", "operationDuration": "PT5M30S" }] }, { "id": "order-2", "stops": [{ "type": "single", "id": "Dauphine", "position": { "lon": 2.274264, "lat": 48.870087 }, "kind": "pickup", "operationDuration": "PT5M30S" }] }, { "id": "order-3", "stops": [{ "type": "single", "id": "Station-f", "position": { "lon": 2.370564, "lat": 48.83476 }, "kind": "pickup", "operationDuration": "PT5M30S" }] } ], "maxOptimizationDuration": "PT1M" }' ``` ```json Response (abridged) theme={null} { "item": { "id": "", "resources": [ ... ], "orders": [ ... ] } } ``` Keep the `id` from the response — every request below uses it in place of ``. `"type": "fly"` is a crow-fly vehicle profile — it's fast to compute and ideal for a first test. Real integrations typically use `car` or `truck` profiles (see the data model reference). You can also submit a plan as a file upload (`-F "file=@plan.json"`) instead of an inline `-d` body, and as XLSX instead of JSON — column names in the spreadsheet match the JSON field names. As soon as the plan is accepted, optimization starts automatically — there's no separate "start" call. ## Step 3 — Retrieve the solution ```bash theme={null} curl "https://.kardinal.ai/api/v2/plans//solution" \ -H "Accept: application/json" \ -H "Authorization: Bearer " ``` The response wraps the result in an `item` field and gives you, per resource, the ordered list of stops (`tours[].wayPoints`) with arrival/departure times, plus any stop that couldn't be planned: ```json Response (abridged) theme={null} { "item": { "planId": "", "planVersion": 1, "unaffectedStopIds": [], "unusedResourceIds": [], "tours": [ { "resourceId": "resource1", "distanceInKm": 10.509, "workingDuration": "PT25M16S", "wayPoints": [ { "type": "stop", "stopId": "Station-f", "arrivalTime": "2023-03-21T08:00:00Z", "stopKind": "pickup" }, { "type": "stop", "stopId": "Balard", "arrivalTime": "2023-03-21T08:11:04Z", "stopKind": "pickup" }, { "type": "stop", "stopId": "Dauphine", "arrivalTime": "2023-03-21T08:19:46Z", "stopKind": "pickup" } ] } ] } } ``` For a small test plan like this one, the solution is typically ready within seconds — for larger plans, poll the plan's `status` field until optimization settles (see the polling workflow in [How the optimization engine works](/concepts/how-the-optimization-engine-works)). ## Next steps * `PUT /plans/` with the same `id` to see interactive re-optimization in action — read [How the optimization engine works](/concepts/how-the-optimization-engine-works) first to understand what happens on update. * Add time windows, capacities, and skills to your orders and resources — see the full data model reference. * Move from crow-fly (`fly`) to a real vehicle profile (`car`, `truck`) before going further than a smoke test. # Modeling advanced constraints Source: https://developers.kardinal.ai/guides/advanced-constraints Heterogeneous capacities, driver skills, mandatory breaks, multiple time windows. The [data model](/reference/data-model) covers the basic shape of a plan. This guide goes one level deeper, into five constructs that come up as soon as a fleet or a business isn't fully uniform: vehicles that aren't interchangeable, drivers with different qualifications, regulatory breaks, stops with more than one valid visiting window, and optional steps that only matter if they gate a mandatory stop downstream. Each section shows the hard version first, then the soft equivalent where one exists — see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) for the general distinction. ## Heterogeneous capacities per vehicle `capacities` is a free-form map: any key you define (`weight`, `volume`, `nbPackages`, a custom unit) is tracked independently by the engine, on both `resources` and `stops`. A resource's capacity is a hard ceiling — the cumulative load of its assigned stops can never exceed it at any point in the tour. ```json theme={null} { "resources": [ { "id": "van-1", "vehicleProfile": { "type": "car" }, "capacities": { "weight": 800, "volume": 4.2, "nbPackages": 60 } } ], "orders": [ { "id": "order-1", "stops": [ { "type": "single", "id": "stop-1", "position": { "lon": 2.3522, "lat": 48.8566 }, "kind": "delivery", "operationDuration": "PT5M", "capacities": { "weight": 40, "volume": 0.3, "nbPackages": 3 } } ] } ] } ``` ### Multi-compartment vehicles A vehicle with several physically separate compartments (ambient / chilled / frozen, for example) doesn't need every compartment to be full at once — it needs *at least one* valid combination of loads to fit. Model each compartment as its own capacity key, and use `atLeastOneValidCapacity` to check feasibility across the whole set rather than requiring all of them simultaneously: ```json theme={null} "additionalConstraints": [ { "type": "atLeastOneValidCapacity", "name": "fits-one-compartment-configuration", "capacities": { "ambient": 400, "chilled": 250, "frozen": 150 } } ] ``` `atLeastOneValidCapacity` checks that a valid state exists somewhere in the tour sequence — it doesn't pin which compartment holds which stop. Use it for feasibility ("can this load physically fit"), not for sequencing which stop type comes first (that's a separate use of the same mechanism, not covered here). ### Tolerated capacity overflow By default, exceeding a capacity makes a stop unplannable rather than degrading gracefully. If the business would rather accept an occasional overload than drop a stop, give the resource a `cost.costsByCapacity` with a steep `overcostCoeff` above a target `costFloor` instead of a hard capacity ceiling: ```json theme={null} { "id": "van-1", "capacities": { "weight": 850 }, "cost": { "costsByCapacity": { "weight": { "costCoeff": 1.0, "costFloor": 800, "overcostCoeff": 1000000, "overcostFloor": 850 } } } } ``` This makes overflow economically unattractive — the engine avoids it whenever another solution exists — without ever declaring the stop infeasible on capacity grounds alone. ## Driver skills and qualifications `requiredSkills` on an order and `skills` on a resource are the hard match: a resource can only be assigned an order if it has *every* skill the order requires (a certification, an equipment qualification, and so on). ```json theme={null} { "resources": [ { "id": "technician-1", "skills": ["gas-certification", "electrical-certification"] } ], "orders": [ { "id": "order-1", "requiredSkills": ["gas-certification"], "stops": [ { "...": "..." } ] } ] } ``` When the match should be a preference rather than a requirement — a senior technician *should* handle a demanding job, but a generalist can still cover it if needed — tag the stop and use `preferredStopTags` on the resource with `maximizePreferredStops` in the objectives, instead of `requiredSkills`: ```json theme={null} { "resources": [ { "id": "technician-senior", "preferredStopTags": ["skill:senior-preferred"] } ], "orders": [ { "id": "order-1", "stops": [ { "id": "stop-1", "tags": ["skill:senior-preferred"], "...": "..." } ] } ], "objectives": ["maximizeMandatoryStops", "maximizePreferredStops", "minimizeCosts"] } ``` The engine will assign the tagged stop to a matching resource when it can do so without hurting higher-priority objectives, but will fall back to any capable resource rather than leave the stop unplanned. ## Mandatory breaks A resource's `breaks` array accepts three break types, and they aren't mutually exclusive — combine them to model a realistic shift: | Type | Triggered by | Typical use | | ----------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `timeWindowBreak` | A fixed clock time | A lunch break that must happen inside a specific window, regardless of workload | | `workingDurationSlidingBreak` | Cumulative working time | Labor-contract rules — duration varies by sector/collective agreement, don't assume a figure here without confirming it | | `travelDurationSlidingBreak` | Cumulative driving time | Road-safety regulation — e.g. EU Regulation (EC) 561/2006 sets a public, jurisdiction-wide minimum of 45 min after every 4h30 of driving for professional road transport, regardless of employer | ```json theme={null} { "id": "driver-1", "workingTimeWindow": { "begin": "2026-08-03T05:00:00Z", "end": "2026-08-03T20:00:00Z" }, "breaks": [ { "type": "workingDurationSlidingBreak", "minBreakDuration": "PT30M", "maxInterBreakDuration": "PT6H" }, { "type": "travelDurationSlidingBreak", "minBreakDuration": "PT45M", "maxInterBreakDuration": "PT4H30M" } ] } ``` The two rows above aren't interchangeable in how confidently you can default them: the driving-time figure is set by public statute independent of any single employer, so `PT45M`/`PT4H30M` is safe to apply outright once you've established the jurisdiction (see the [agent modeling checklist](/getting-started/agent-modeling-checklist)). The working-time figure is typically set by a sector- or company-level collective agreement layered on top of a lower statutory floor, so a single number here would be wrong for many employers — don't copy a duration from this table for `workingDurationSlidingBreak`; treat it as an open question for client confirmation instead. Both sliding breaks count the same break toward their respective counters — the engine looks for a single moment that satisfies both regulations rather than scheduling them separately. For long shifts, define both the driving-time and the working-time rule together; a single sliding break only covers one of the two regulatory clocks. ## Multiple time windows per stop `authorizedTimeWindows` accepts an array, so a stop can have more than one hard, disjoint window — for example, a site open in the morning and again in the late afternoon, closed in between: ```json theme={null} { "id": "stop-1", "authorizedTimeWindows": [ { "begin": "2026-08-03T08:00:00Z", "end": "2026-08-03T12:00:00Z" }, { "begin": "2026-08-03T16:00:00Z", "end": "2026-08-03T20:00:00Z" } ] } ``` The engine treats these as independent options — the stop is feasible if it can be reached in *any* one of them. `preferredTimeWindows` layers a soft target on top (see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints)); it's always intersected with the authorized windows, so it narrows the target without ever widening what's actually reachable. ### Restricting a window to specific resources A window can also apply only to resources carrying a given tag — for instance, an early access slot reserved for a certified subcontractor, while other resources only see the standard window: ```json theme={null} { "id": "stop-1", "authorizedTimeWindows": [ { "begin": "2026-08-03T06:00:00Z", "end": "2026-08-03T08:00:00Z", "resourceTags": ["subcontractorA"] }, { "begin": "2026-08-03T08:00:00Z", "end": "2026-08-03T18:00:00Z" } ] } ``` A resource without the matching tag simply doesn't have the tagged window available to it — it's constrained to whichever windows apply to everyone. ## Optional steps that gate a mandatory stop Not every optional step needs its own `AlternativesStop`. Only reach for it when skipping the step would make a *downstream mandatory stop* infeasible — for example an order that needs to collect a spare part or piece of equipment before a mandatory service visit, but only when the resource isn't already carrying it. If a step is either always required or never affects feasibility, a plain `SingleStop` (or no extra stop at all) is enough; `AlternativesStop` exists for the case where whether it's needed depends on context the plan itself has to resolve, not on something you can decide upfront. **This is a per-order decision, applied unconditionally to every order with this dependency — it is not an aggregate feasibility check on whether enough of the item exists across the fleet.** Whether *this* order's assigned resource already happens to be carrying the item, on *this* specific tour, is something the engine resolves per instance; it isn't something you can decide upfront from a stock count. A fleet-wide total that looks "sufficient" says nothing about whether any single resource, on any single tour, already has the unit it needs at the point it's needed — so don't skip modeling the `AlternativesStop` for an order just because some aggregate check elsewhere says the item isn't scarce. That aggregate question — "can this load physically fit at all, across the fleet" — is a separate, complementary mechanism: see [Multi-compartment vehicles](#multi-compartment-vehicles) above and `atLeastOneValidCapacity` in the [constraints catalog](/reference/data-model#constraints-catalog). Don't conflate the two: an aggregate feasibility check answers "is there enough overall"; `AlternativesStop` answers "does *this* resource need a detour, right now, on this tour" — and the two questions can have opposite answers on the same plan. **The gate and the mandatory stop it conditions are two stops of the *same* `Order` — not two separate `Order`s.** Put the `AlternativesStop` gate first in that order's `stops` array and the mandatory stop right after it: array position doubles as precedence here, same as everywhere else in the API (see [Order, Stop](/reference/data-model#order-stop)). Splitting them into two `Order`s is the single most common way to get this pattern wrong — it looks reasonable, validates against the schema, and still produces a materially different plan, because a solver-assigned mandatory stop can then land on a *different* resource, or a different position in the tour, than the gate that was supposed to precede it. Model the gated step as an `AlternativesStop` with two candidates — a real "fetch" stop, and a zero-effect placeholder the engine can pick instead whenever the fetch isn't necessary — as the **first** stop of the order, immediately followed by the mandatory stop it conditions as the **second** stop of that same order. The item being fetched is typically an item a resource may already be carrying: a pooled resource drawn down by the mandatory stop and topped back up by the optional fetch immediately before it, both within the same order. Because both stops belong to the same order, the resource's running capacity balance already carries from one to the next by default — no pooling flag is needed for this. `sharedCapacities` (see [Shared capacity pools](/reference/data-model#shared-capacity-pools)) is a *different*, additional mechanism: it's for when the pool must be shared *across* separate orders on the same resource, which isn't what this pattern needs. ```json theme={null} { "resources": [ { "id": "resource-1", "vehicleProfile": { "type": "car" }, "capacities": { "toolkit-available": 1, "toolkit-placed": 1 } } ], "orders": [ { "id": "order-1", "stops": [ { "type": "alternatives", "id": "fetch-if-needed", "alternatives": [ { "type": "single", "id": "fetch-at-supply-point", "position": { "lat": 48.86, "lon": 2.35 }, "kind": "pickup", "operationDuration": "PT10M", "capacities": { "toolkit-available": -1 } }, { "type": "single", "id": "no-detour-needed", "position": { "lat": 48.80, "lon": 2.40 }, "operationDuration": "PT0S" } ] }, { "type": "single", "id": "mandatory-visit", "position": { "lat": 48.80, "lon": 2.40 }, "kind": "delivery", "operationDuration": "PT30M", "capacities": { "toolkit-placed": 1 } } ] } ], "additionalConstraints": [ { "type": "atLeastOneValidCapacity", "name": "toolkit-fetched-before-placed", "capacities": { "toolkit-placed": 0, "toolkit-available": -1 } } ] } ``` **Don't set an `atLeastOneValidCapacity` threshold equal to the resource's own ceiling for that key** — that's the identical trap called out in [Shared capacity pools](/reference/data-model#shared-capacity-pools). A resource's base `capacities` already forbid exceeding the ceiling at *every* point in the tour (a universal check); `atLeastOneValidCapacity` only asks whether the listed capacities are at or below the given thresholds at *some* point (an existential check). If the threshold equals the ceiling, the existential check is automatically satisfied wherever the universal one already is, and the additional constraint adds nothing. Here the threshold on `toolkit-available` has to sit **strictly below** the resource's ceiling of `1` — this example uses `-1`, not `1` — precisely so that a plan which never fetches can never reach it. `toolkit-placed`'s threshold is tightened to `0` for the same reason, even though it isn't the one doing the discriminating below. `order-1`'s `mandatory-visit` — listed **second**, right after the gate — needs one unit already on board to be feasible, and that unit is supplied by the *same order*'s `fetch-at-supply-point`, listed first. Array position is what makes "earlier in the tour" mean anything here — no separate order and no `sharedCapacities` flag required, since both stops already belong to one order on one resource. The engine picks `fetch-at-supply-point` — paying its extra travel and `operationDuration` — only when nothing earlier in the tour already put a unit into the pool; otherwise it picks the zero-effect `no-detour-needed` placeholder, whose position matches the very next stop so it adds no extra travel at all. This choice is made independently for every order with this dependency, from the actual, resource-specific state of the pool at that point in that tour — never from a fleet-wide count of how many units exist in total. Both alternatives are still mandatory to *evaluate* — the choice itself isn't optional, only its real-world outcome (a detour, or none) is. Trace the numbers to see why each branch lands where it does. `fetch-at-supply-point` is a `pickup` stop with a **negative** `capacities` value, `{ "toolkit-available": -1 }`: a `pickup` *adds* its `capacities` value to the running total, so adding `-1` nets to a decrease — the sign of the value carries the real effect here, not `kind` (see the note on `kind` vs. the sign of `capacities` under [Order, Stop](/reference/data-model#order-stop)). `mandatory-visit` never lists `toolkit-available` at all, so nothing else in the order ever touches it. * **If `fetch-at-supply-point` is picked:** `toolkit-available` goes from `0` to `-1` right after the fetch, and stays at `-1` through `mandatory-visit` (which doesn't touch it). At that state, `toolkit-available` (`-1`) is at or below the constraint's `-1` threshold, and `toolkit-placed` (still `0`, since `mandatory-visit` hasn't run yet) is at or below its `0` threshold — both listed capacities are satisfied *together*, so `atLeastOneValidCapacity` is satisfied and the plan is feasible. * **If `no-detour-needed` is picked instead:** `toolkit-available` never moves off `0` — nothing in this branch ever writes to it. `0` is well within the resource's ceiling of `1`, so the *base* capacity check has nothing to object to. But `0` is never at or below `-1`, at any point in the tour, in this branch — `mandatory-visit` running afterward only changes `toolkit-placed`, not `toolkit-available`. So `atLeastOneValidCapacity` is never satisfied, and the plan is correctly rejected as infeasible. That's the concrete sequencing this constraint rules out: skipping the fetch and still performing `mandatory-visit` is fully ceiling-compliant (`toolkit-available` never exceeds `1`; `toolkit-placed` never exceeds `1`) — an "otherwise valid-looking" plan by the base capacity check alone — but it's rejected anyway, because it can never produce the `-1` state the tightened constraint demands. A reader can re-run this trace against the JSON above to confirm it. Notice the two decoupled capacity keys, `toolkit-available` (whether the fetch has already happened, tracked as a signed credit) and `toolkit-placed` (consumed by the mandatory stop), rather than a single `toolkit` key shared by both stops. `mandatory-visit` never lists `toolkit-available`, and `fetch-at-supply-point` never lists `toolkit-placed` — the two keys stay fully independent capacities, each still validated and reportable on its own terms. A single net key would collapse two different questions the solver needs to answer separately — "how much has this order already placed" and "how much is currently available to it" — into one number that only reports their difference, hiding which side is actually short whenever the pattern needs to be checked or reported on independently. It's the plan-level `atLeastOneValidCapacity` [additional constraint](/reference/data-model#constraints-catalog) — not a shared running total — that ties the two independent keys together into the "fetch before place" rule. `AlternativesStop` increases optimization time — see [Sizing `maxOptimizationDuration` for a large problem](/guides/handling-large-volumes#sizing-maxoptimizationduration-for-a-large-problem). Reserve it for steps whose necessity genuinely depends on the rest of the plan; don't reach for it just to express "this step is optional" when a plain `optional: true` order, or leaving the step out entirely, would do. ## See also * [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) — the general hard/soft distinction referenced throughout this page. * [Handling infeasibility](/guides/handling-infeasibility) — what happens when none of the above can be satisfied for a given stop. * [Data model](/reference/data-model) — full field reference for `capacities`, `skills`, `breaks`, time windows, and stops. # Authentication and API keys Source: https://developers.kardinal.ai/guides/authentication Key generation, rotation, and storage best practices. The Kardinal API authenticates every request with a JWT `access_token`, sent as a bearer token. This guide covers how to obtain one, keep it valid, store it safely, and what to do when authentication fails. Kardinal currently provisions access by inviting a user (typically your company email) to set a password on an environment such as `https://.kardinal.ai` — there is no separate, long-lived "API key" to generate from a dashboard. Your username/password pair is what's exchanged for short-lived tokens below. If your integration needs a different credential model (e.g. a dedicated service account), raise it with your Account Executive. ## Obtain an access token ```bash theme={null} curl -X POST "https://.kardinal.ai/api/v2/login" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"username":"","password":""}' ``` ```json Response theme={null} { "access_token": "", "refresh_token": "", "user": { ... } } ``` Send the `access_token` on every request: ``` Authorization: Bearer ``` ## Token lifetime and refresh An `access_token` is valid for **one hour**. This short lifetime limits the damage of a leaked token, but it means your integration must refresh proactively rather than waiting for a request to fail: * Call the `login` endpoint again, or * Use the `refresh_token` you received at login against the `refreshToken` endpoint to get a new `access_token` without re-sending the password. A practical pattern is to refresh a few minutes before the hour is up (e.g. on a 50-minute timer), so requests never race against expiry. ## Storing credentials Treat the username/password and any live `access_token` / `refresh_token` like any other production secret: * Store them in environment variables or a secrets manager (Vault, AWS/GCP/Azure secret managers, etc.) — never hard-code them in source control. * Keep sandbox and production credentials in separate secrets, scoped to separate deployment environments. * Log requests without the `Authorization` header value; if you need to debug a 401, log the response body, not the token. * If a password or refresh token is suspected to be compromised, contact your Kardinal support channel (`api@kardinal.ai` or your Account Executive) to have access reset — there is no documented self-service revocation endpoint at this time. ## Common authentication issues | Symptom | Likely cause | What to do | | --------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Requests start failing after \~1 hour of otherwise working fine | `access_token` expired | Refresh via `refreshToken` (or re-login) before the hour mark, proactively | | Every request fails immediately, including the first one | Wrong `username`/`password`, or wrong `` host | Re-check credentials and the environment URL you were given at onboarding | | Requests fail with no `Authorization` header sent | Header missing or malformed | Confirm the header is exactly `Authorization: Bearer ` (note: some internal examples in older docs use `Authorisation` — the standard spelling `Authorization` is what the API expects) | Exact HTTP status codes and error bodies for these cases will be documented in [Error codes](/reference/error-codes) once confirmed against the live API. # Handling infeasibility Source: https://developers.kardinal.ai/guides/handling-infeasibility Interpret a no-solution response and diagnose the constraint at fault. Kardinal doesn't reject an infeasible plan outright — see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints#why-a-stop-not-always-the-problem-becomes-infeasible) for why. The API still returns `200` with a solution; infeasibility shows up as specific stops or resources being left out of it. This guide covers how to recognize that and work back to the cause. ## How to recognize an infeasibility response There is no dedicated "infeasible" status — check these fields on the solution instead: * **`unaffectedStopIds`** — non-empty means at least one stop could not be planned within the hard constraints. This is the most common signal and the one to check first. * **`unusedResourceIds`** — non-empty means at least one resource was left with nothing assigned. This isn't necessarily a problem (it can be `minimizeResources` doing its job) — see the third bullet under "Common causes" below before assuming it's a bug. * **`tours[].isValid: false`** — a specific tour violates a hard constraint. This is rarer in practice (the engine generally avoids constructing an invalid tour rather than returning one), but check it per-tour rather than assuming the plan is fine because the top-level call succeeded. If none of these are populated, the plan is fully feasible as submitted — a "worse than expected" result (too much delay, an unbalanced fleet) is a soft-constraint or objective-ordering question, not an infeasibility one; see [How the optimization engine works](/concepts/how-the-optimization-engine-works) instead. ## Diagnostic method: which constraint is at fault For each stop in `unaffectedStopIds`, walk through the hard constraints in [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) and check them against that stop and the resources that could plausibly serve it: 1. **Time windows.** Is there any resource whose `workingTimeWindow` overlaps at least one of the stop's `authorizedTimeWindows`, once travel time to/from the stop is accounted for? A window that's technically non-empty but unreachable given travel time is the single most common cause — especially if the window came from a `preferredTimeWindows` field that was accidentally modeled as `authorizedTimeWindows` (see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints#a-contractual-window-is-not-automatically-a-hard-one)); a window that's a hard constraint by mistake turns a should-be-late delivery into a dropped one. 2. **Skills.** Does any available resource's `skills` array cover every one of the stop's order's `requiredSkills`? A single missing skill on every resource is enough to make the stop unserviceable. 3. **Capacities.** Does any resource declare every capacity key the stop consumes, with enough remaining headroom at that point in a plausible tour? Remember capacities are free-form — a typo in a key name (`weight` vs `Weight`) silently makes the stop unmatchable to every resource, with no error raised. 4. **Order structure.** If the order has `successiveStops` or a `maxStopSpan`, check whether the timing implied by other constraints (time windows, breaks) makes that structural requirement impossible to satisfy alongside them. 5. **Resource-level bounds.** Would serving this stop push a resource over its `maxWorkingDuration`, `maxDistanceInKm`, or `maxInterStopDistanceInKm` / `maxInterStopDuration`? Work through these in order — time windows and skills are the fastest to rule in or out and cause the large majority of real-world infeasibility. ## Resolution strategies Once you've identified the binding constraint, the fix is usually one of: * **Relax the constraint**, if it was set stricter than the business actually requires — the most common example is switching a contractual delivery window from `authorizedTimeWindows` to `preferredTimeWindows` once you confirm a late visit is acceptable. * **Add or reassign a resource** — a stop with no resource combination that satisfies its skills/capacity/time-window requirements simply needs one that does; this is a fleet-sizing problem more than a modeling one. * **Lower the stop's `priority` or mark it `"optional": true`** if it's acceptable for it to be dropped under pressure from higher-priority stops — this doesn't fix infeasibility, but it makes the trade-off explicit and intentional instead of an unplanned side effect. * **Check `additionalConstraints`/`globalConstraints`** (`incompatibleStopTags`, `forbiddenAssignment`, `maxStopTagGroups`, `maxCumulatedCost`, etc.) for anything scoped to the stop's or resource's tags — these are easy to forget once a plan has grown past its first few constraints. ## See also * [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) — the conceptual explanation of which fields are walls and which are targets, referenced throughout the diagnostic method above. * [Data model](/reference/data-model) — full field reference for everything checked in the diagnostic steps. * [Modeling advanced constraints](/guides/advanced-constraints) — worked examples for capacities, skills, breaks, and multiple time windows. # Handling large volumes Source: https://developers.kardinal.ai/guides/handling-large-volumes Batch import, pagination, and async mode for long-running calculations. This guide covers the three things that change once a plan or an integration outgrows a small smoke test: how to submit many orders at once, how to page through large result sets, and how to size `maxOptimizationDuration` for a bigger problem. ## Batch order import There's no separate "batch" endpoint — a single plan already carries its full `orders` array, so importing in bulk means submitting all of it in one `PUT` request rather than one order at a time. As shown in [First API call](/getting-started/first-api-call), you can submit either: * an inline JSON body (`-d`) with the complete `resources`/`orders` arrays, or * a file upload (`-F "file=@plan.json"`), as JSON or as XLSX — column names in the spreadsheet match the JSON field names. For a large order set sourced from a spreadsheet or a WMS/TMS export, the file-upload path avoids building and escaping one large JSON string by hand. Whichever path you use, submit the whole plan in one call — updating a plan by adding orders incrementally (many small `PUT`s to the same plan `id`) is supported (see [How the optimization engine works](/concepts/how-the-optimization-engine-works#continuous-and-interactive-optimization)) but re-triggers optimization on every update, which is slower than one large submission for an initial import. The maximum payload size accepted per request is not yet published in this documentation — see [Limits and quotas](/reference/limits-and-quotas). If you're importing an unusually large order set, confirm the ceiling with [support@kardinal.ai](mailto:support@kardinal.ai) before building an automated pipeline around a single large request. ## Paginating results List endpoints such as `GET /plans` are unpaginated by default — every matching record is returned in one response. Pass `page` and/or `itemsPerPage` as query parameters to switch to paging: * `itemsPerPage` — records per page (default `20`, maximum `100`). * `page` — 1-indexed page number (default `1`). If you set only one of the two, the other falls back to its default rather than disabling paging — paging only stays off if both are omitted. The response wraps the collection in a `paging` object (`page`, `nextPage`, `previousPage`, `itemsPerPage`) so you can walk forward without recomputing offsets yourself. ```bash theme={null} curl "https://.kardinal.ai/api/v2/agencies//plans?page=2&itemsPerPage=50" \ -H "Accept: application/json" \ -H "Authorization: Bearer " ``` ## Sizing `maxOptimizationDuration` for a large problem There's no published lookup table mapping problem size to an exact optimization duration — how long a plan needs depends on more than just stop and resource counts (see the full list of drivers in [How the optimization engine works](/concepts/how-the-optimization-engine-works#what-you-control-vs-what-the-engine-controls)): whether resources use `withTraffic: true`, and whether advanced constraints (`AlternativesStop`, `removalStrategy: "lifo"`, `overlappingCapacitiesByStopTag`) are in play — any of these can switch the engine to a markedly slower algorithm regardless of raw problem size. In practice, size `maxOptimizationDuration` empirically rather than guessing a fixed value up front: 1. Start with the [polling pattern](/concepts/how-the-optimization-engine-works#the-quality-vs-computation-time-trade-off) (Kardinal's recommended integration pattern) with a generous `maxOptimizationDuration` ceiling (e.g. `PT1H`) — the engine stops early on its own once it stops finding improvements, so an overly long ceiling costs you nothing but a slightly longer worst case. 2. Watch how long it actually takes to converge (successive polls stop showing objective improvements) for your real problem size and configuration. 3. For recurring plans of similar shape (same rough stop/resource count, same constraint set), use that observed convergence time, with margin, as your steady-state `maxOptimizationDuration` instead of re-discovering it every time. 4. Re-run this calibration whenever problem size changes by an order of magnitude, or when you turn on `withTraffic` or an advanced constraint for the first time — both are known to change convergence time independently of stop/resource count. This converges faster than picking an arbitrary starting value. A common mistake is assuming small-plan durations like `PT10M` scale up to fleets of **10+ vehicles and 100+ orders** — they don't. A much longer budget, on the order of **hours rather than minutes** (e.g. `PT4H`), is often needed to reach a comparable solution quality at that size. As a starting point to calibrate from — not a substitute for the empirical loop above: | Problem size | Starting `maxOptimizationDuration` | | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | ≤10 stops, no advanced constraints | `PT10M` | | \~50-100 stops / 5-15 resources | `PT1H`-`PT4H` | | 100+ stops, or `overlappingCapacitiesByStopTag`/`AlternativesStop`/`withTraffic` in play | `PT4H`+, then recalibrate empirically from observed convergence | ## See also * [How the optimization engine works](/concepts/how-the-optimization-engine-works) — objectives, the `maxOptimizationDuration` trade-off, and the three integration patterns (static, iterative, polling). * [Limits and quotas](/reference/limits-and-quotas) — payload size, rate limits, and the waiting-room throughput mechanism. # Migrating between API versions Source: https://developers.kardinal.ai/guides/migrating-api-versions Version differences, deprecations, and a migration checklist. How-to guide — priority P2. ## To be written * API versioning policy * Differences between the current and previous version * Migration checklist * Deprecation timeline # Multi-trip tours (depot returns) Source: https://developers.kardinal.ai/guides/multi-trip-tours Model a vehicle that reloads at the depot and runs several rounds within one shift. Not every fleet can serve its full daily demand in a single loop. When total demand across a wave of orders exceeds the fleet's total capacity, resources need to return to the depot, reload, and go out again — potentially several times — inside their `workingTimeWindow`. Nothing about this requires a dedicated field: it's a modeling pattern built from `capacities` and stop `kind`, the same primitives covered in the [data model](/reference/data-model). Before modeling multi-trip tours, check whether you actually need them, using the exact test from the [agent modeling checklist](/getting-started/agent-modeling-checklist) (§2, "Check capacity feasibility before modeling orders"): sum your stops' `capacities` per dimension and compare against your fleet's total capacity in a single loop. That test is a strict binary, not a margin call — total demand either **fits** within total fleet capacity (single-loop, no reload needed) or **exceeds** it (single-loop is infeasible, not merely suboptimal; the shortfall will show up as unplanned stops, not an error). This page assumes that check already came out on the "exceeds" side. It isn't a separate, softer threshold of its own, and a ratio close to but under 1 doesn't independently justify reaching for this pattern — per the checklist, don't resolve a capacity shortfall by defaulting to multi-trip on your own; surface it as an open question, and only model this pattern once the client's own data or context confirms the fleet genuinely reloads at a depot mid-shift. ## The core mechanic: pair each delivery with its own depot pickup Rather than pre-computing a fixed number of "reload rounds" per vehicle, pair every delivery with a pickup of the *same* cargo at the depot, inside the *same* order: ```json theme={null} { "id": "order-school-1", "stops": [ { "type": "single", "id": "pickup-school-1", "position": { "lat": 48.888, "lon": 2.614 }, "kind": "pickup", "operationDuration": "PT1S", "capacities": { "weight": 130, "piles": 3 }, "tags": ["depot:main"] }, { "type": "single", "id": "delivery-school-1", "position": { "lat": 48.899, "lon": 2.598 }, "kind": "delivery", "operationDuration": "PT11M", "capacities": { "weight": 130, "piles": 3 }, "preferredTimeWindows": [{ "begin": "2026-08-03T06:00:00+02:00", "end": "2026-08-03T10:30:00+02:00" }] } ] } ``` Since an order's stops all run on the *same* resource, in array order, and the pickup adds exactly what the delivery then removes, each pair is capacity-neutral by construction — it never touches the resource's baseline load. That has two consequences that make it a better default than sizing a handful of large reload stops to a resource's full capacity: * **No fixed round count.** The engine is free to chain any number of these pairs on a single resource, going back to the depot as many times as the schedule and capacity allow — you don't need to guess in advance how many rounds each vehicle will need, or leave unused `"optional": true` reload orders on the table. * **No per-vehicle sizing.** A pickup sized to one delivery's own weight/piles fits *any* resource with that much spare capacity, heterogeneous fleet or not. There's no need for a `skills` / `requiredSkills` pair to reserve a reload for a specific vehicle — whichever resource ends up serving the delivery automatically picks up its own matching cargo first, because both stops belong to the same order. If you also want to force or bias *which* stops happen before versus after each other (for example, a fixed morning sector followed by a fixed afternoon sector), combine this with tagged phases and the `maximizePrecedences` objective. That's a separate concern from the capacity mechanic above. ## Charging depot time once per visit, not once per pickup With one pickup stop per delivery, a vehicle loading five deliveries' worth of cargo before a round would otherwise pay `operationDuration` five times over for what is physically a single dock visit. Keep each pickup's `operationDuration` negligible (as in the example above) and instead charge the real access time once per visit with the plan-level `accessDurationsByStopTag` field: ```json theme={null} { "accessDurationsByStopTag": { "depot:main": "PT30M" } } ``` This adds the duration once, before the first of a run of *consecutive* stops sharing the tag — matching a single loading operation at the dock, regardless of how many individual pickups happen during that visit. See [Data model](/reference/data-model#plan-level-fields) for the full field. ## Limiting simultaneous depot visits A physical depot usually has a limited number of loading docks or bays, and can't serve every vehicle at once. Tag every pickup stop with a shared depot tag (as in the example above) and cap simultaneous presence with `overlappingCapacitiesByStopTag` at the plan level: ```json theme={null} { "overlappingCapacitiesByStopTag": { "depot:main": 2 }, "objectives": [ "maximizeMandatoryStops", "minimizeDelay", "minimizeOverOverlappingCapacitiesOnStops", "minimizeResources", "minimizeLargestTourDuration", "minimizeWorkingDuration", "minimizeDistance" ] } ``` This caps the number of resources simultaneously present at any stop tagged `depot:main` to 2 — matching, for example, a depot with two loading docks. Because every load in this pattern is an explicit tagged stop that's part of an order — including the *first* load of the day, not just later reloads — the limit applies uniformly from the very first pickup, with nothing left uncovered. `departure` and `arrival` (see [Depot vs. position](/reference/data-model#depot-vs-position)) are only used for the resource's idle start/end-of-day position; they carry no cargo and no tag, so keep the actual loading out of them entirely. Without a fairness objective, the engine can load one or two resources up to their limit on repeated depot rounds while others in the fleet sit comparatively idle — all while still satisfying `maximizeMandatoryStops` and the delay/cost objectives ahead of it. Include `minimizeLargestTourDuration` (see [How the optimization engine works](/concepts/how-the-optimization-engine-works#what-the-engine-optimizes)) to cap how unbalanced the longest single tour can get relative to the rest of the fleet — it's placed after `minimizeResources` above so fleet size is still minimized first, but tour lengths are then balanced across whatever fleet size that settles on. If resources are arriving back at the depot and then waiting idle for their next reload window, consider setting the plan-level `lateDeparture: true` (see [Plan-level fields](/reference/data-model#plan-level-fields)) so the engine compacts departure timing to reduce that idle wait, instead of always having resources leave as early as possible. ## Swap-body and container-exchange orders A different, unrelated vertical also produces multi-stop orders on a single resource: fleets where the vehicle carries exactly one exchangeable unit at a time — a container, a skip ("benne"), a tank — and each trip's job is to exchange whatever unit is already at a site for the next one, then haul the unit away to its destination (a disposal site, a treatment plant, a depot). Roll-off/skip trucks and tanker/cistern-swap vehicles are the two most common examples. This is a distinct pattern from the depot-reload pattern above — it isn't about a vehicle running low on capacity and returning to top up, it's about a vehicle that only ever carries one unit and needs to swap it out at (or near) the point of use. ### Two stop shapes, decided by what actually happens at the site Whether a given exchange needs two stops or three is a question about the data, not a stylistic default to pick between: * **Three stops, when an exchange happens at the origin.** The vehicle already has a unit to drop off before it can take on the next one: a `delivery` stop drops the returning/empty unit, immediately followed by a `pickup` stop that takes on the next unit — both **at the same position** — and then a third stop, at a *different* position, delivers/hauls the newly picked-up unit to its destination. * **Two stops, when no exchange happens at the origin.** If the vehicle already has nothing to drop off at the pickup site (for example it starts the trip already running with an empty unit loaded, or the site has nothing to collect first), the order is a plain `pickup` at the origin followed by a `delivery` elsewhere — the same shape used elsewhere in this guide and in [Data model](/reference/data-model#order-stop). Don't standardize on one shape across a fleet or a dataset. Each order should reflect what its own site actually requires: if the source data (or the client) confirms a real drop-off-then-pickup happens at that origin, model three stops; if nothing is dropped off there, two stops is the correct — not a simplified — model. ### Preventing double-booking: three legitimate mechanisms A vehicle that carries only one unit at a time must never be assigned a second pickup before the current unit is off-loaded. There are three legitimate ways to enforce that, and they are not mutually exclusive — a real payload may use one, several, or all together, depending on what else the data needs to express: 1. **`successiveStops` alone.** Set `successiveStops: true` on the order (see [Order, Stop](/reference/data-model#order-stop)) with no tags, no `setupDurations`, and no capacity signal at all. Array order plus `successiveStops` is already enough to guarantee the drop-off and the pickup happen back-to-back, in that order, with nothing else interleaved — which is all that's needed to prevent double-booking when the exchange itself has no real handling-time cost worth modeling and there's no capacity dimension the exchange needs to express. This is often the simplest correct option, and in practice the one actually used, when neither of the other two mechanisms' extra machinery corresponds to anything real in the data — don't reach for tags + `setupDurations` by default on every swap-body order just because the pattern involves an exchange; reserve it for exchanges that genuinely carry their own timing cost (see the next option). 2. **Tags + `setupDurations`, combined with `successiveStops`.** Tag the relevant stops with something that captures the vehicle's state (for example a tag meaning "carrying a unit" versus "empty"), and use the plan-level `setupDurations` array to charge a duration whenever a resource transitions from a stop tagged one way to a stop tagged another way — see the field's own definition in the OpenAPI reference (`SetupDuration`: `fromStopTag`, `toStopTag`, `setupDuration`). Set `successiveStops: true` on the order so nothing from another order can be interleaved between the drop-off and the pickup. Together, these charge a realistic handling/swap duration for the exchange itself and guarantee the two visits happen back-to-back, in that order. 3. **Signed `capacities` pairs.** Track the unit with two capacity dimensions that move in lockstep and opposite directions — one incrementing, one decrementing at each step (for example a stop that changes `capacities` by `{"unit": 1, "missingUnit": -1}`, and the matching drop-off stop by the inverse). The running capacity balance itself then prevents a second pickup from happening before the current unit is dropped off, because doing so would push a dimension out of range. This is the same family of mechanism as [shared capacity pools](/reference/data-model#shared-capacity-pools), applied within a single order rather than across several. A bare count-based ceiling on its own — for example `capacities: {"units": 1}` on the resource and on every stop that adds or removes a unit, with no `successiveStops`, no tags/`setupDurations`, and no signed pair — is **not**, by itself, a correct model of this pattern once a real drop-off-then-pickup step exists at the same site. A capacity ceiling only bounds how many units are aboard at any point; it doesn't add the exchange's own handling time, and nothing about it forces the drop-off and the pickup to happen back-to-back without another order's stop landing in between. Use it only when the trip is genuinely the simpler two-stop shape above (no exchange at the origin) — and even then, prefer whichever of the three mechanisms above the client's data actually calls for once an exchange is involved. ### Example: tags + `setupDurations` for a three-stop exchange ```json theme={null} { "setupDurations": [ { "fromStopTag": "unit:empty", "toStopTag": "unit:loaded", "setupDuration": "PT20M" }, { "fromStopTag": "unit:loaded", "toStopTag": "unit:empty", "setupDuration": "PT20M" } ], "orders": [ { "id": "order-container-exchange-1", "successiveStops": true, "stops": [ { "type": "single", "id": "order-container-exchange-1-dropoff", "position": { "lat": 48.85, "lon": 2.35 }, "kind": "delivery", "operationDuration": "PT1M", "tags": ["unit:empty"] }, { "type": "single", "id": "order-container-exchange-1-pickup", "position": { "lat": 48.85, "lon": 2.35 }, "kind": "pickup", "operationDuration": "PT1M", "tags": ["unit:loaded"] }, { "type": "single", "id": "order-container-exchange-1-haul", "position": { "lat": 48.70, "lon": 2.10 }, "kind": "delivery", "operationDuration": "PT5M", "tags": ["unit:loaded"] } ] } ] } ``` The `PT20M` figure here is illustrative, not a universal constant — it stands in for whatever real handling/administrative time a given client's exchange actually takes, which you should confirm against their own operation rather than assume. Because the drop-off and pickup stops share the same position, the resource's travel time between them is zero; the `setupDurations` entry is what charges the exchange's own realistic duration on top of that, and `successiveStops: true` guarantees nothing else gets scheduled in between. The final haul stop keeps the `unit:loaded` tag since the resource is still carrying the unit it just picked up — no further transition (and so no further setup duration) is charged until it next transitions to a differently-tagged stop. Option 2 (signed `capacities` pairs) doesn't need a new worked example here — it follows the same [Capacities](/reference/data-model#capacities) mechanics already documented, just applied with two dimensions per unit instead of one. ## See also * [Data model](/reference/data-model) — `capacities`, stop `kind`, `accessDurationsByStopTag`, and the rest of the constraints catalog referenced above. * [Modeling advanced constraints](/guides/advanced-constraints) — heterogeneous capacities, skills, breaks, and multiple time windows. * [Handling infeasibility](/guides/handling-infeasibility) — diagnosing a plan where demand still doesn't fit even with multiple rounds. # Real-time re-optimization Source: https://developers.kardinal.ai/guides/real-time-reoptimization Trigger a recalculation following a disruption (delay, cancellation, urgent order). There is no dedicated "re-optimize now" endpoint. A running plan is always subject to re-optimization the moment its data changes — see [Continuous and interactive optimization](/concepts/how-the-optimization-engine-works#continuous-and-interactive-optimization). Reacting to a disruption is a matter of updating the right part of the plan, not calling a separate recalculation API. ## Types of triggering disruptions | Disruption | What changed | How to reflect it | | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | A resource is running late | Its real availability window shrank | Update that resource's `workingTimeWindow` (or `departure`) in the plan's `resources` array, then `PUT /plans/{planId}` with the full plan. | | An order is cancelled | A stop no longer needs to be served | Remove that order from the plan's `orders` array, then `PUT /plans/{planId}` with the full plan. | | An urgent new order comes in | A new stop must be added mid-shift | Add the new order to the plan's `orders` array with a new `orderId`, then `PUT /plans/{planId}` with the full plan. | | A specific resource/stop pairing must be excluded (e.g. a breakdown makes a resource unable to reach a stop it was going to serve) | A resource can no longer serve a specific order | This case doesn't have a confirmed pattern in the current API yet — contact [support@kardinal.ai](mailto:support@kardinal.ai) for guidance in the meantime. | Every one of these is the same operation: mutate the relevant part of your local copy of the plan, then `PUT` the whole plan back — there is no endpoint that targets a single resource or order in isolation anymore. ## How this differs from a full recalculation Submitting any of the updates above keeps the plan's `id` unchanged, so it isn't treated as a new problem: the engine degrades the previous solution only as much as needed to stay valid for the new data, then keeps improving from there — it does not restart optimization from scratch. This is normally much faster than the plan's original optimization, which had to learn the problem's shape for the first time. If an older version of the plan is still optimizing when a disruption update lands, the engine drops the stale version in favor of the newest one — you never get two versions "optimized" at once. ## Best practices for call frequency * **Batch disruptions that arrive close together** into a single update where possible, rather than issuing one `PUT` per individual change — every update re-triggers optimization, and back-to-back updates just cause the engine to keep abandoning a version it hasn't finished with yet. * **Poll before you push another update.** Check the plan's `status` (see [How the optimization engine works](/concepts/how-the-optimization-engine-works#the-quality-vs-computation-time-trade-off)) to see whether the previous update has already settled; there's little value in sending a new disruption update while the engine is still mid-search on the last one, beyond the disruption itself needing to be reflected immediately. * **Don't reduce `maxOptimizationDuration` for these updates** just because they feel like small edits — the field caps *this version's* remaining search time, and a busy fleet mid-shift can still take real time to re-settle around a disruption; size it the same way you would for the initial plan (see [Handling large volumes](/guides/handling-large-volumes#sizing-maxoptimizationduration-for-a-large-problem)). ## See also * [How the optimization engine works](/concepts/how-the-optimization-engine-works) — what happens when a plan is updated with the same `id`, and the three integration patterns for retrieving a solution. * [Handling infeasibility](/guides/handling-infeasibility) — diagnosing a disruption that leaves a stop unplannable rather than just delayed. * [Data model](/reference/data-model) — full field reference for `Resource` and `Order`, the two objects most commonly touched by a disruption. # Moving from sandbox to production Source: https://developers.kardinal.ai/guides/sandbox-to-production Environment differences and a production launch checklist. Sandbox and production are two separate environments, each with its own `https://.kardinal.ai` host, its own credentials, and its own data — nothing you create in one is visible from the other. Moving to production is mostly a matter of pointing your integration at the new host with new credentials and re-validating what you already tested, rather than a code change. ## Differences between sandbox and production | Aspect | What differs | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Environment URL | Each environment has its own `.kardinal.ai` host, provisioned separately by your Account Executive — see [First API call](/getting-started/first-api-call). | | Credentials | Sandbox and production each have their own username/password and resulting `access_token`/`refresh_token` — a sandbox token does not work against the production host, or vice versa. See [Authentication and API keys](/guides/authentication). | | Data | Plans, resources, and orders are entirely separate per environment — nothing in sandbox carries over automatically. | | Rate limits, max payload size, SLA | Not yet published for either environment — confirm the values provisioned for your account with [support@kardinal.ai](mailto:support@kardinal.ai) before relying on a specific ceiling. See [Limits and quotas](/reference/limits-and-quotas). | ## Checklist before switching to production 1. **Confirm account-specific quotas** (rate limit, max payload size, simultaneous-running-plans threshold) with [support@kardinal.ai](mailto:support@kardinal.ai) — these aren't published and can differ from what sandbox happened to tolerate. 2. **Re-run your integration against production with real data volumes.** If your production order/resource counts are meaningfully larger than what you tested in sandbox, re-check your `maxOptimizationDuration` sizing — see [Handling large volumes](/guides/handling-large-volumes#sizing-maxoptimizationduration-for-a-large-problem) rather than assuming sandbox-derived durations still apply. 3. **Re-verify geocoding.** Kardinal does not geocode addresses (see [Data model](/reference/data-model#order-stop)); confirm the same geocoding provider and pipeline used in sandbox testing is wired up for production data before go-live. 4. **Point any webhooks at the production environment's URLs**, if your integration uses them — a sandbox-configured webhook won't fire for production plans. 5. **Rotate to production credentials everywhere**, including any long-lived config or secrets manager entries — see the next section. ## Managing API keys per environment Kardinal doesn't issue a separate long-lived "API key" — each environment has its own username/password pair, exchanged for short-lived tokens as described in [Authentication and API keys](/guides/authentication). For production: * Store sandbox and production credentials as **separate secrets**, scoped to their respective deployment environments, so a staging deploy can never accidentally authenticate against production (or vice versa). * Confirm which environment a given `access_token` was issued against before debugging a request that behaves unexpectedly — a token from the wrong environment fails authentication rather than silently hitting the wrong data. * If you're automating token refresh (see [Token lifetime and refresh](/guides/authentication#token-lifetime-and-refresh)), point the refresh logic at the same `.kardinal.ai` host the original login used. ## See also * [Authentication and API keys](/guides/authentication) — obtaining, refreshing, and storing credentials. * [Limits and quotas](/reference/limits-and-quotas) — rate limits, payload size, and SLA, and how to confirm the values for your account. * [Handling large volumes](/guides/handling-large-volumes) — sizing `maxOptimizationDuration` and paginating for production-scale data. # Changelog Source: https://developers.kardinal.ai/reference/changelog API version history. Reference — priority P2. Update this page with every notable API change. ## Upcoming Describe the changes for the next version here. # Data model Source: https://developers.kardinal.ai/reference/data-model Dictionary of the Vehicle, Order/Task, Depot, Constraint, and Time window objects. A plan is built from two lists — `resources` (vehicles/drivers) and `orders` (what needs to be done) — plus plan-level fields that add constraints spanning several of them. This page is the field dictionary; see [First API call](/getting-started/first-api-call) for the request/response shapes and [How the optimization engine works](/concepts/how-the-optimization-engine-works) for what the engine does with these fields. ## Resource (vehicle-driver pair) A `Resource` represents one vehicle-driver pair for the duration of the plan. Only `id`, `vehicleProfile`, and `workingTimeWindow` are required — everything else defaults to "unconstrained." | Field | Type | Description | | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Unique within the plan. Allowed characters: unaccented letters, digits, and `- . _ ~ : @ ! $ ,` (case-sensitive) — an existing identifier (a plate, a SKU) using these is valid verbatim, no need to slugify it. | | `vehicleProfile` | object | Mode of transport and its travel constraints — one of `fly`, `pedestrian`, `bicycle`, `scooter`, `motorbike`, `car`, `truck`. Each type exposes different parameters; `truck` is the richest (weight, dimensions, hazardous goods, toll/tunnel/highway avoidance — see the section below). | | `workingTimeWindow` | [TimeWindow](#time-windows) | When the resource is available to work. Often paired with `maxWorkingDuration` — a wide window doesn't mean a long shift if the duration is capped. Can span more than one calendar day — see [Modeling a multi-day plan](#modeling-a-multi-day-plan). | | `maxWorkingDuration` | duration | Caps total working time (travel + service + breaks + waiting) inside `workingTimeWindow`. | | `capacities` | map | See [Capacities](#capacities). | | `skills` | string\[] | Qualifications this resource has. Matched against an order's `requiredSkills` — see [Modeling advanced constraints](/guides/advanced-constraints). | | `preferredStopTags` | string\[] | Soft counterpart to `skills`/`requiredSkills` — a preference, not a requirement. Used with the `maximizePreferredStops` objective. | | `tags` | string\[] | Free-form tags, referenced by plan-level mechanisms (`additionalConstraints`, `costsByResourceTag`, `accessDurationsByStopTag`, and so on). | | `departure` / `arrival` | [Position](#depot-vs-position) | Start/end coordinates of the resource's route — typically a depot. If omitted, the working day starts/ends at the first/last visited stop instead. | | `breaks` | array | See [Breaks](#breaks). | | `maxDistanceInKm` | number | Caps total distance traveled — constrains the resource's service area. | | `maxInterStopDistanceInKm` / `maxInterStopDuration` | number or object | Caps the distance or duration between two consecutive stops. Either a single value applied to every leg, or an object with independent `firstTravel` / `interStop` / `lastTravel` bounds — useful to cap only the commute from/to a technician's home without limiting travel between jobs. | | `priority` | integer | `0` by default, can be negative. Lower is more important — see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints). | | `cost` | object | Custom cost model (per km, per capacity unit, fixed cost) used by `minimizeCosts`. | | `properties` | map (string → string) | Free-form metadata (for example the driver's name or the vehicle's plate) with no effect on optimization — returned unchanged in the plan and its solution. | `capacities`, `skills`, and tags are all free-form: the API doesn't predefine `weight` or `forklift` as special values. Whatever keys you use on a resource must match the keys used on the stops/orders you expect it to serve. ### Modeling a multi-day plan `workingTimeWindow` isn't limited to a single calendar day. Sizing it to span several days — or a full week — is the correct pattern whenever an order's stops legitimately fall on different dates and all of them still need to be planned onto **one** resource: a pickup stop on one day and its paired delivery stop two days later, for example, requires a resource whose `workingTimeWindow` brackets both timestamps, because an `Order`'s `stops` must all be planned onto the same resource (see [Order, Stop](#order-stop)). Splitting the same fleet into a separate `Resource` per calendar day instead breaks that pairing: no single one of those daily resources could serve both the pickup and the delivery stop of the same order. A wide `workingTimeWindow` does **not**, on its own, force any rest between working days — nothing about the field stops the engine from scheduling a stop late on one day and another early the next. If a daily-rest requirement applies, it must be encoded independently in `breaks[]` (see [Breaks](#breaks)): a multi-day window and a daily-rest rule are two separate mechanisms, and setting one is not a substitute for the other. One concrete way to encode daily rest inside a multi-day `workingTimeWindow`: repurpose `travelDurationSlidingBreak` with duration figures sized for a full night's rest rather than an in-shift driving break — for example `minBreakDuration` around `PT11H` (the rest period itself) triggered after `maxInterBreakDuration` around `PT9H` of cumulative driving (the daily driving allowance before rest is due), so the sliding-break mechanism produces one long rest per day instead of several short ones. A second, equally valid mechanism is stacking one `timeWindowBreak` entry per calendar day the plan spans, each pinned to a fixed nightly clock time (for example `22:00`–`06:00` on every date in the window) — better suited when the rest period should anchor to the clock rather than to cumulative driving time. Either way, the daily-rest requirement needs its own explicit `breaks[]` entry; it is never implied by `workingTimeWindow` alone. If the daily-rest pattern above and an in-shift driving break (see [Breaks](#breaks)) both apply to the same resource, the recommended approach is two separate `travelDurationSlidingBreak` entries in the same `breaks[]` array rather than trying to compress both purposes into one — a short-threshold entry for the in-shift driving break (e.g. `PT45M` after `PT4H30M`) alongside a long-threshold entry for the daily-rest cycle (e.g. `PT11H` after `PT9H`). Each entry is its own object with its own `minBreakDuration`/`maxInterBreakDuration` pair, and the data model places no restriction on repeating the same `breaks[]` type with different thresholds — this is the documented way to express both requirements at once, rather than a sign that one purpose needs a different break type from the other. ### Vehicle profiles `vehicleProfile.type` determines both the routing mode and which extra parameters are available: | Type | Adds | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fly` | Crow-fly distance, `kmph` — no road network, fastest to compute. Good for smoke tests, not real routing. | | `pedestrian`, `bicycle`, `scooter`, `motorbike`, `car` | Road-network routing for the corresponding mode. | | `truck` | Everything `car` has, plus `grossWeight`, `height`, `width`, `length`, `avoidTollRoad`, `avoidTunnel`, `tunnelCategory`, `avoidFerry`, `avoidSeasonalClosure`, `avoidControlledAccessHighway`, `avoidDirtRoad`, `avoidUTurns`, `shippedHazardousGoods`, `excludedCountries`, `speedCap` — everything needed to respect road restrictions for heavy or hazardous-goods vehicles. | Setting `type: "truck"` and leaving every `truck`-only field unset does **not** silently apply any default legal-truck-road restriction — routing behaves exactly like `car` until you actually set one of those fields. So the choice between `car` and `truck` only matters once you populate at least one restriction field; if you don't know a vehicle's dimensions yet, default to `car` (a delivery van with no known dimensions is a `car`, not an under-specified `truck`) and switch to `truck` once real dimensions or road restrictions are available. This default is for the case where dimensions are genuinely unknown, not for the case where the vehicle's own name already answers the question. If a vehicle-type name in your source data is itself an unambiguous heavy-vehicle designation (a truck, lorry, semi-trailer, tractor-trailer, or an equivalent term in the client's own language) rather than a generic label like "van" or "car", treat that name as sufficient evidence to pick `truck` even with no numeric dimensions yet — don't fall back to `car` just because the restriction fields are still empty. The no-dimensions default exists for genuinely ambiguous or generic vehicle descriptions, not for names that already name a heavy-vehicle class. `withTraffic: true` (available on road-network profiles) enables predictive traffic, billed separately — see [How the optimization engine works](/concepts/how-the-optimization-engine-works). For any plan where `preferredTimeWindows` / `authorizedTimeWindows` matter — which is almost every real delivery scenario — default to `withTraffic: true`. Travel-time estimates computed without it are systematically optimistic, which silently erodes on-time performance against exactly the time windows you're trying to hit. Confirm the added billing with the client/account team, but don't let cost-consciousness alone default this to off: it isn't something to leave out by default just because nothing in your source data mentions it. ```json theme={null} "vehicleProfile": { "type": "car", "withTraffic": true } ``` ## Order, Stop An `Order` is one or more `stops` that must all be planned onto the *same* resource, in array order (position in the array acts as a precedence constraint). Only `id` and `stops` are required. | Field | Type | Description | | ----------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Unique within the plan. Allowed characters: unaccented letters, digits, and `- . _ ~ : @ ! $ ,` (case-sensitive) — an existing identifier (a plate, a SKU) using these is valid verbatim, no need to slugify it. | | `stops` | array | Ordered list of stops — see below. | | `optional` | boolean | If `true`, the order can be left unplanned without affecting `maximizeMandatoryStops` (though `maximizeOptionalStops` still tries to place it). Shorthand for giving it the lowest priority. | | `priority` | integer | `0` by default, can be negative. | | `requiredSkills` | string\[] | Hard requirement — a resource must have every listed skill to be assignable. | | `successiveStops` | boolean | The order's stops must be visited back-to-back, with nothing from another order in between. Mutually exclusive with `maxStopSpan`. | | `maxStopSpan` | duration | Maximum time allowed to elapse between the order's stops (e.g. between a pickup and its delivery), without forcing them to be consecutive. | | `properties` | map (string → string) | Free-form metadata (for example a client reference) with no effect on optimization — returned unchanged in the plan and its solution. | A **stop** is either a `SingleStop` (only `id` and `position` are required) or an `AlternativesStop` — a list of `SingleStop` candidates the engine picks the best one from (for example several possible disposal sites; increases optimization time). See [Optional steps that gate a mandatory stop](/guides/advanced-constraints#optional-steps-that-gate-a-mandatory-stop) for a worked example of using this to model a preceding step that's only needed in some contexts, rather than a fixed choice among equivalent sites. | Field | Type | Description | | ------------------------------------------------ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Unique within the plan. Allowed characters: unaccented letters, digits, and `- . _ ~ : @ ! $ ,` (case-sensitive) — an existing identifier (a plate, a SKU) using these is valid verbatim, no need to slugify it. | | `position` | lat/lon | Must be pre-geocoded — see the note below. | | `kind` | `pickup` \| `delivery` \| `acknowledgement` | A generic operation label from the resource's own point of view, **not** a guarantee of real-world physical direction. It determines how the stop's `capacities` affect the resource's load: **added** for a pickup, **deducted** for a delivery, **ignored** for an acknowledgement (used to model an intervention with no cargo exchange). | | `operationDuration` | duration | Time spent at the stop. | | `capacities` | map | Consumed/released at this stop — see [Capacities](#capacities). | | `authorizedTimeWindows` / `preferredTimeWindows` | array of [TaggedTimeWindow](#time-windows) | See [Time windows](#time-windows). | | `tags` | string\[] | Free-form, `prefix:suffix` by convention (not enforced) — referenced by plan-level mechanisms. | | `properties` | map (string → string) | Same free-form metadata bag as on `Resource` (for example a customer name or address) — no effect on optimization. | **The real-world direction a stop moves goods in is carried by the *sign* of `capacities`, not by `kind`.** `kind` only names the generic operation (a collection, a drop-off, or a no-cargo intervention) and fixes the add/deduct/ignore rule above; it is not itself proof of which physical direction goods move. A dataset can use `kind: "pickup"` on every single stop in the plan and still model both collections and drop-offs correctly, simply by flipping the sign of `capacities` instead of switching `kind`. Treat this as the load-bearing rule for `kind` — not a side note: get it wrong and a payload can look plausible while silently tracking every stop's load in the wrong direction. If you need to reconstruct the real-world direction of a stop after the fact (for a report, for instance), read the sign of its `capacities` values together with `kind` — the combination, not `kind` in isolation, tells you whether the resource's load actually went up or down at that stop. Id-uniqueness is scoped per collection, not global: `resources[].id`, `orders[].id`, and `stops[].id` are each their own namespace, so the same string can be reused across `resources`, `orders`, and `stops` in the same plan without a collision. Always set the `type` field on a stop explicitly (`"type": "single"` for a `SingleStop`), even though the API defaults it and a bare stop object will be accepted as-is. A discriminated-union validator (for example a typed SDK re-parsing your own payload to confirm it round-trips) needs the tag present in the data itself to pick the right stop variant, and rejects an object that's missing it even when that field has a documented default — so a payload that only works because the tag was left to its default won't survive that kind of check. `operationDuration` has no built-in formula — it's a plain duration you compute upstream from your own business data (for example a fixed per-stop time plus a variable component based on quantity delivered). Nothing on the API side derives it for you; get this calculation wrong and the plan stays valid (no error), just unrealistic. Kardinal does not geocode addresses — every `position` you submit must already be a `{lat, lon}` pair. If your source data (a client spreadsheet, a CRM export) only has addresses, convert them to coordinates with a geocoding provider (for example Google Maps Geocoding API, Mapbox Geocoding, or the BAN/Base Adresse Nationale API for French addresses) before building the plan. Getting this wrong doesn't raise an error — a valid-looking but incorrectly geocoded position (e.g. a commune centroid instead of the real address) still produces a plan, just one that's routed to the wrong place. ## Capacities `capacities` is a free-form map (`{"weight": 2200, "volume": 9.5, "nbPackages": 23}`) on both resources and stops — any key you invent is tracked as its own independent dimension. A resource can only serve a stop if every capacity key the stop consumes is also declared on the resource, and the running total never exceeds the resource's value for that key at any point in the tour. See [Modeling advanced constraints](/guides/advanced-constraints) for multi-compartment vehicles and tolerated overflow. ### Count-based vs. size-based ceilings for "one item at a time" vehicles Some vehicles carry exactly one item at a time, but the item's own size varies from trip to trip — for example a truck that carries a single container, skip, or tank per load, where different units hold different volumes. Two capacity representations both correctly enforce "never more than one aboard," with a real trade-off between them: * **Count-based ceiling** — for example `capacities: {"containers": 1}` on the resource and on every stop that adds or removes a unit. The ceiling tracks *how many* items are aboard, not their size, so it's robust to item-size variance: a fleet with mixed unit sizes needs no per-trip capacity tuning, and you never need to know or record any individual item's real volume for the constraint to work. * **Size-based ceiling** — for example `capacities: {"volume": }`, sized to the largest unit the resource can ever carry. This loses the size-invariance of the count-based approach (a resource sized for the largest unit looks under-loaded whenever it's actually carrying a smaller one), but it's the representation that supports overflow or partial-load reporting — a stop that only partially fills the ceiling, or exceeds it, is visible in the numbers. A count of `1` can't express that: it's already at its ceiling regardless of the item's real fill level. Pick count-based when the only requirement is "one item at a time" and nothing downstream needs the item's size; pick size-based when the capacity value itself needs to carry fill-level or overflow information. A capacity ceiling isn't the only way to keep a "one item at a time" vehicle from being double-booked, and isn't always sufficient on its own: when swapping the item at a site has its own real handling-time cost (dropping off the current unit before picking up the next one), tagging the relevant stops and charging that duration with the plan-level `setupDurations` field — combined with `successiveStops` on the order — is a separate, valid mechanism, one that a bare capacity ceiling can't express on its own. ### Shared capacity pools By default, a resource's capacities are just its own running load — nothing ties one order's stops to another's beyond sharing the same ceiling. Set the plan-level `sharedCapacities` to `true` (see [Plan-level fields](#plan-level-fields)) when several *different* orders assigned to the same resource need to draw down and top back up the *same* running balance — for example a limited stock of reusable equipment or exchangeable containers that a resource carries and moves between stops over the course of one tour, rather than a fixed cargo that's loaded once and only ever decreases. ```json theme={null} { "sharedCapacities": true, "resources": [ { "id": "resource-1", "vehicleProfile": { "type": "car" }, "capacities": { "equipmentPool": 100 } } ], "orders": [ { "id": "order-1", "stops": [ { "type": "single", "id": "order-1-stop", "position": { "lat": 48.85, "lon": 2.35 }, "kind": "delivery", "capacities": { "equipmentPool": 10 } } ] }, { "id": "order-2", "stops": [ { "type": "single", "id": "order-2-stop", "position": { "lat": 48.87, "lon": 2.40 }, "kind": "pickup", "capacities": { "equipmentPool": 10 } } ] } ], "additionalConstraints": [ { "type": "atLeastOneValidCapacity", "name": "equipment-pool-stays-in-range", "capacities": { "equipmentPool": 100 } } ] } ``` `order-1`'s stop deducts 10 units from `equipmentPool`, and `order-2`'s stop — from a *different* order, later in the same tour — adds 10 back. The two only interact because `sharedCapacities` is `true`: without it, nothing guarantees that stops from separate orders read and write the same running balance on the resource. Pair `sharedCapacities` with an `atLeastOneValidCapacity` [additional constraint](#constraints-catalog) whenever the business rule is stronger than "never exceed the ceiling at any single point" — for instance requiring the pool to reach a specific state at some point in the tour. The example above uses the pool's own ceiling as the threshold; tighten it (for example to a value close to zero) if the rule requires the pool to actually return to a stricter level at some point, rather than just staying under its maximum. A related pattern is when the step that tops the pool back up is itself optional — needed only when the pool hasn't already been replenished earlier in the tour. That case is usually scoped to a *single* order (the optional fetch and the mandatory stop it feeds are two stops of the same order, not two separate orders), so it doesn't need `sharedCapacities` at all — the default per-order pooling above is enough. See [Optional steps that gate a mandatory stop](/guides/advanced-constraints#optional-steps-that-gate-a-mandatory-stop) for the worked example, including why it decouples the pool into two separate capacity keys instead of one net key. Reach for `sharedCapacities` on top of that pattern only if the fetch and the stop it feeds end up split across genuinely different orders on the same resource. ## Time windows Two related shapes: * **`TimeWindow`** — plain `{begin, end}`, used for `workingTimeWindow` and break windows. * **`TaggedTimeWindow`** — the same shape plus an optional `resourceTags` array, used for `authorizedTimeWindows` / `preferredTimeWindows` on a stop. When `resourceTags` is set, that window only applies to resources carrying a matching tag (for example an early slot reserved for a certified subcontractor); resources without the tag only see the untagged windows. A stop's `authorizedTimeWindows` accepts an array, so more than one disjoint hard window is native (a site open mornings and again in the evening, for instance) — the stop is feasible if reachable in *any* one of them. See [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) for how `preferredTimeWindows` interacts with the authorized ones. ## Breaks A resource's `breaks` array mixes any of three types — they aren't mutually exclusive, and breaks count as working time unless the type says otherwise. | Type | Required fields | Triggered by | | ----------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `timeWindowBreak` | `duration`, `timeWindow` | A fixed clock time — acts like a `workingTimeWindow` + `maxWorkingDuration` pair scoped to the break itself. | | `workingDurationSlidingBreak` | `minBreakDuration`, `maxInterBreakDuration` | Cumulative working time. | | `travelDurationSlidingBreak` | `minBreakDuration`, `maxInterBreakDuration` | Cumulative driving time. | The two sliding-break types count the same physical break toward both clocks — define both together for a shift with both a labor-time rule and a driving-time rule, rather than assuming one covers the other. For a resource whose `workingTimeWindow` spans more than one calendar day, `breaks[]` is also where daily rest has to be encoded — a wide window does not produce it on its own. See [Modeling a multi-day plan](#modeling-a-multi-day-plan) for a worked pattern. The API has no way to infer `maxInterBreakDuration` (or which sliding-break type applies) from a break *duration* alone. Source data often gives you only "30 min break" with no stated trigger — that's a labor-agreement or ops-policy detail, not something derivable from the break length itself. Don't default to a generic legal minimum (e.g. a national labor-law figure) without confirming it against the client's actual collective agreement or dispatch rules — the two are frequently different, and the gap directly changes where breaks get scheduled. If you need to submit a first end-to-end payload before the client's break rules are confirmed, a `timeWindowBreak` spanning most of the shift (rather than a guessed sliding-break trigger) is a safer placeholder — clearly flag it as provisional and pending client confirmation rather than treating it as correct. ## Constraints catalog Beyond what `resources`/`orders`/`stops` express directly, a plan can carry `additionalConstraints` (scoped to specific tags) and `globalConstraints` (scoped to the whole fleet): | Type | Scope | What it does | | ------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `atLeastOneValidCapacity` | `additionalConstraints` | The tour must pass through at least one state where the listed capacities are all at or below the given thresholds — used for sequencing stop types or validating multi-compartment configurations. | | `forbiddenAssignment` | `additionalConstraints` | Bars a specific resource tag from a specific stop tag — a targeted exclusion, narrower than `incompatibleStopTags`. | | `incompatibleStopTags` | `additionalConstraints` | Two stop tags can never appear in the same tour, regardless of resource. | | `atLeastOneConstraint` | `additionalConstraints` | Combines several constraints with OR logic — the tour is valid if at least one is satisfied. | | `capacities` | `additionalConstraints` | Fine-grained capacity constraint beyond the base resource/stop matching. | | `maxStopTagGroups` | `additionalConstraints` | Caps how many separate visits to stops sharing a tag a resource can make — for instance limiting depot returns, or forcing a shared-equipment tag to stay with a single resource across the tour. | | `removalStrategy` | `additionalConstraints` | Enforces a last-in-first-out unloading order on a resource: with `strategy: "lifo"`, a resource can only unload the last thing it loaded — not a rule about which stops get dropped from an over-constrained plan. See note below for a worked example. | | `maxCumulatedCost` | `globalConstraints` | Caps a cost total (for example number of active resources in a tag group) across the whole fleet, not just one tour. | `removalStrategy: "lifo"` is the mechanism for a **stacked or sequential-loading vehicle** — a car carrier loading vehicles nose-to-tail on a single deck, or a multi-deck cage truck — where physically nothing can be unloaded except the item that went on last. Set it on the resource tag(s) that represent that vehicle class; the engine then only sequences stops in an order consistent with last-in-first-out access, instead of allowing a mid-stack item to be unloaded before the ones loaded after it. `overlappingCapacitiesByStopTag` (plan-level, paired with the `minimizeOverOverlappingCapacitiesOnStops` objective) is a related but distinct mechanism: it caps how many resources can be **physically present at the same time** at stops sharing a tag — the native way to model a shared bottleneck resource such as a depot with a limited number of loading docks, or a cross-dock with limited simultaneous capacity. `accessDurationsByStopTag` (plan-level) is a third tag-based mechanism, easy to confuse with the two above: it adds a fixed extra duration once, before the first of a group of *consecutive* stops sharing a tag — for example a single 30-minute dock access time charged once per depot visit, no matter how many individual pickups happen during that visit. This is distinct from a stop's own `operationDuration`, which is charged on every stop individually regardless of what came before it: use `accessDurationsByStopTag` for a cost paid once per visit to a tagged location, and `operationDuration` for a cost paid per stop. All of the above are enforced as hard constraints unless the mechanism is explicitly cost-based (`maxCumulatedCost`, custom `cost` objects) — see [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) for the general distinction. ## Plan-level fields A few fields configure the plan as a whole rather than any single resource or order: | Field | Type | Description | | ------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The plan's own identifier. | | `agencyId` | string, read-only | Scopes the plan to a Kardinal tenant/account context — derived automatically from the access token used to make the request, not something you set in the payload. An access token that grants access to more than one agency is rejected with a `403` before it reaches this scoping step. **This is not a physical-depot or site partition key.** A single fleet whose resources are based at multiple physical depots/sites can, and normally should, still be submitted as **one** `Plan` — don't split a plan into multiple `Plan` objects just because your resources have different `departure`/`arrival` positions. Splitting by depot is only correct if the depots genuinely belong to different tenants/accounts, which is a business fact to confirm, not something to infer from resources having different positions. | | `tz` | string (IANA timezone, e.g. `"Europe/Paris"`) | Lets every datetime in the plan be written without its own UTC offset (`"2026-08-03 06:00"` instead of `"2026-08-03T06:00:00+02:00"`) — the offset is resolved against this timezone instead. Optional: datetimes can also carry an explicit offset directly, in which case `tz` isn't needed. | | `lateDeparture` | boolean | `false` by default. When `true`, resources are allowed to depart later than the earliest feasible moment inside their `workingTimeWindow` if doing so doesn't hurt the objectives, instead of always leaving as early as possible. | | `maxOptimizationDuration` | duration | Caps how long the engine is allowed to search. A value sized for a small smoke test (e.g. `PT10M`) is usually far too short once a plan grows past roughly 10+ vehicles and 100+ orders — see [Sizing `maxOptimizationDuration` for a large problem](/guides/handling-large-volumes#sizing-maxoptimizationduration-for-a-large-problem) before picking a value for a fleet that size. | | `sharedCapacities` | boolean | `false` by default. When `true`, a capacity key is pooled across *all* of a resource's assigned stops for the whole tour, regardless of which order each stop belongs to, instead of only interacting within a single order — see [Shared capacity pools](#shared-capacity-pools). | ## Depot vs. position `Depot` is a standalone, agency-level object (`id`, `name`, `position`, `address`, `isMain`) conceptually distinct from a plan's `Resource.departure`/`arrival` positions. Its own CRUD endpoints are **not** part of this API reference — don't expect to find them by browsing these pages or the `openapi.yaml` spec here, and don't spend time searching for them. It is **not** referenced by `id` inside a plan either way: a resource's `departure`/`arrival` normally take a raw `{lat, lon}` position — typically resolved from one of your `Depot` objects. `arrival` (but not `departure`) can alternatively be set to the literal string `"atFirstPosition"` instead of a position, meaning the resource returns to wherever its tour actually started rather than to a separate, fixed depot point — useful when a tour should end where it began without pinning that point to a specific coordinate ahead of time. If you manage depots as first-class objects on your side, resolve them to coordinates yourself before building the plan — there is currently no `depotId` field linking a resource back to a `Depot`. The `Depot` object also has no opening-hours field; model a depot's operating hours through the `workingTimeWindow` of the resources that start/end there. These are two distinct mechanisms with two very different costs of adoption — don't reach for the second one when the first is all you need. ### Opening hours vs. shift window: the depot's opening hours win If your source data gives you **both** a site/depot opening-hours figure (e.g. "warehouse open 05:30–16:00") **and** a separate per-vehicle shift window (e.g. a driver's "06:00–23:59" shift on a timesheet), don't default to the vehicle-level figure alone, and don't intersect the two either — set the resource's `workingTimeWindow` to the **depot's opening hours**. In practice, a per-vehicle shift window this wide relative to the depot hours is typically a payroll/administrative boundary (the driver's paid shift), not a physical-availability constraint — it doesn't narrow what the vehicle can actually do at the depot, so it shouldn't narrow `workingTimeWindow` either. Only fall back to intersecting the two (or to the vehicle figure alone) if you've confirmed with the client that the per-vehicle window reflects a real operational constraint (e.g. a driver who genuinely cannot start before a fixed time regardless of the depot being open) rather than an administrative one. This is a plain `workingTimeWindow` computation — it doesn't require modeling the depot as a stop, and it's unrelated to the pattern below. ### Loading docks, dock time, or reload trips: model the depot as a tagged stop A depot with limited simultaneous dock capacity, a fixed per-visit dock/access time, or a fleet that needs to reload there mid-shift is a **different, heavier pattern**: it requires restructuring orders to include an explicit pickup stop at the depot (tagged, e.g. `depot:main`), plus plan-level `accessDurationsByStopTag` and/or `overlappingCapacitiesByStopTag`. Unlike the intersection rule above, this *is* a structural change to your `orders` array — see [Multi-trip tours](/guides/multi-trip-tours) for the full worked pattern, including when it's actually needed (compare total stop demand against total fleet capacity first). ## See also * [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) — which of the above bend under pressure and which don't. * [Modeling advanced constraints](/guides/advanced-constraints) — worked examples for capacities, skills, breaks, time windows, and optional steps that gate a mandatory stop. * [How the optimization engine works](/concepts/how-the-optimization-engine-works) — objectives and the optimization loop that consumes this data. # Error codes Source: https://developers.kardinal.ai/reference/error-codes Table of business errors and their meaning, beyond generic HTTP codes. Every error response — whatever the HTTP status — shares the same envelope, `EnvelopedErrors`, wrapping one or more `Error` objects: ```json theme={null} { "errors": [ { "code": "INVALID_VALUE", "message": "The field value is not valid." } ] } ``` | Field | Type | Description | | ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code` | string | Stable identifier for the error type. Branch your error handling on this field, not on the HTTP status alone or on the response's description in the OpenAPI spec (see the section below on this) — several `code` values can share the same status. | | `message` | string | Default, human-readable message. Useful for logs; not meant to be parsed. | | `properties` | object | Optional extra context for the error, when the API provides any. Not documented per `code` yet — see the warning further down this page. | `errors` is an array: a single response — typically a `400` on plan/resource creation — can report several invalid fields at once. ## Reference table The API currently defines 11 business error codes: | `code` | HTTP status | Default message | Returned when | | ----------------------- | ----------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `INVALID_INPUT` | 400 | The payload cannot be parsed. | The request body isn't valid JSON, or doesn't match the expected schema (wrong type, missing required field). | | `ID_NOT_UNIQUE` | 400 | The payload contains a collection with an id repeated multiple times. | A collection in the payload (e.g. `resources`, `orders`) has the same `id` on more than one item. | | `KEYS_NOT_UNIQUE` | 400 | The payload contains a collection with the keys repeated multiple times. | A collection has a repeated business key other than `id` (e.g. a tag or reference field expected to be unique). | | `INVALID_ID_REFERENCE` | 400 | The id reference contains an id that does not exist. | A field references an `id` that doesn't match anything else in the payload or system (e.g. a `resourceId` that doesn't exist). | | `INVALID_VALUE` | 400 | The field value is not valid. | A field's value fails a validation rule — format, range, or allowed values. | | `PRECONDITION_FAILED` | 400 | A precondition failed. | The action requires the target resource to be in a particular state, and it isn't (e.g. acting on an already-deleted or already-archived object). | | `NOT_IMPLEMENTED` | 400 | Not yet implemented. | The requested behavior is recognized but not available yet.
Despite the name, this returns **400**, not 501 — treat it as a client-facing "not supported" rather than a server capability gap. | | `NOT_AUTHENTICATED` | 401 | The caller is not authenticated. | No access token was sent, or it's missing, malformed, or expired. See [Authentication and API keys](/guides/authentication). | | `NOT_ALLOWED` | 403 | The requested action is not allowed. | The caller is authenticated but doesn't have permission for this specific action. | | `NOT_FOUND` | 404 | The requested object could not be found. | The resource addressed by the URL doesn't exist, or isn't visible to the caller (the API doesn't distinguish the two, to avoid leaking existence of resources you can't access). | | `INTERNAL_SERVER_ERROR` | 500 | The server encountered an unexpected condition that prevented it from fulfilling the request. | Unexpected server-side failure, unrelated to the request's content. If this persists, contact support with the request's timestamp. | This list is not yet exposed as a formal `enum` on the `Error.code` field in the API reference schema — it's compiled from the current server implementation. If you're generating a client from `openapi.yaml`, don't assume this table is exhaustive for future versions; check the [changelog](/reference/changelog) when upgrading. ## Same HTTP status, different `code` The HTTP status alone doesn't tell you which business error occurred — most statuses map to several codes (all of `INVALID_INPUT`, `ID_NOT_UNIQUE`, `KEYS_NOT_UNIQUE`, `INVALID_ID_REFERENCE`, `INVALID_VALUE`, `PRECONDITION_FAILED`, and `NOT_IMPLEMENTED` return **400**). Always read `code`, not just the status. Conversely, you may see a `403` documented under either "Forbidden" or "Unauthorized" depending on the endpoint in the API reference — this naming isn't consistent across the spec, but both cases behave identically: HTTP 403 with `code: "NOT_ALLOWED"`. Match on `code`, not on the response name shown in the reference. ## `properties` The exact keys returned in `properties` for each `code` (for example, which field name or invalid value is included in an `INVALID_VALUE` or `INVALID_ID_REFERENCE` error) are not documented in the current API reference and need confirmation from the backend team before being published here. Treat any `properties` content as informational and not stable until this section is updated. ## What this page doesn't cover * **Infeasible plans are not an error.** Submitting a plan the engine can't fully satisfy still returns a normal `200`/`201` — the plan is accepted, and unplanned stops or constraint violations show up in the solution (`unaffectedStopIds`, violations) rather than as an `Error`. See [Handling infeasibility](/guides/handling-infeasibility). * **Rate limiting.** The OTP-related endpoints (`/auth/mfa/resendOTP`, `/login/resendOTP`) return a `429` when called too often, but currently without a structured JSON body — it doesn't follow the `EnvelopedErrors` format described above. ## See also * [Authentication and API keys](/guides/authentication) — token lifecycle and the 401s that come with it. * [Data model](/reference/data-model) — the objects referenced by `INVALID_ID_REFERENCE` and `INVALID_VALUE`. # Glossary Source: https://developers.kardinal.ai/reference/glossary Unified terminology used throughout the Kardinal documentation. Reference — priority P1. One term = one definition, reused identically across all pages (see terminology consistency best practices). | Term | Definition | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Route | The ordered sequence of stops a single resource performs within a plan's solution — called a `tour` in the API response (`tours[]`, with `resourceId`, `distanceInKm`, `workingDuration`, and `wayPoints`). "Route" is the everyday term; `tour` is the field name you'll see in payloads. | | Route plan (or just "plan") | The full request submitted for optimization — `resources`, `orders`, and any plan-level constraints or objectives — called `Plan` in the API. Submitting a plan doesn't return a route directly; it returns a `solution` containing one route (tour) per resource. See [Data model](/reference/data-model). | | Disruption | An event during execution that invalidates part of an already-computed solution and calls for a new one: a delay, a cancellation, or an urgent new order, typically. A disruption is handled by re-optimizing the affected plan, not by starting a new one — see [Real-time re-optimization](/guides/real-time-reoptimization). | | Re-optimization | Submitting an update to a plan that already has a solution, using the **same `id`** (the `version` increments automatically). The engine treats this as "same problem, here's what changed": it degrades the previous solution just enough to stay valid for the new data, then keeps improving from there, rather than searching from scratch. See [How the optimization engine works](/concepts/how-the-optimization-engine-works#continuous-and-interactive-optimization). | | Delivery window | The time window during which a `delivery`-kind stop should or must be visited — `authorizedTimeWindows` (hard: outside of it, the stop can't be planned at all) or `preferredTimeWindows` (soft: reachable outside it, at the cost of `minimizeDelay`). See [Hard vs soft constraints](/concepts/hard-vs-soft-constraints) and [Data model](/reference/data-model#time-windows). | # Limits and quotas Source: https://developers.kardinal.ai/reference/limits-and-quotas Rate limits, maximum payload size, computation time, and SLA. | Item | Sandbox | Production | | -------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Rate limit | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | | Max payload size | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | | Max computation time | Not a fixed platform cap — see below. | Not a fixed platform cap — see below. | | SLA | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | Not yet published — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm for your account. | ## Max computation time There is no platform-wide ceiling on how long a plan can optimize — you control it directly with the plan-level `maxOptimizationDuration` field (see [How the optimization engine works](/concepts/how-the-optimization-engine-works#the-quality-vs-computation-time-trade-off)). Set it as long as your integration can tolerate; the engine stops earlier on its own once it stops finding improvements. The one indirect limit on computation time is throughput, not duration: if your agency already has its maximum number of simultaneous running plans in progress, a new plan waits in the **waiting room** (`status.waitingRoom`) until a slot frees up, before its own `maxOptimizationDuration` clock effectively starts mattering. That simultaneous-plans threshold is account-specific — contact [support@kardinal.ai](mailto:support@kardinal.ai) to confirm the value provisioned for your agency. ## See also * [How the optimization engine works](/concepts/how-the-optimization-engine-works) — the quality-vs-time trade-off and the `status` lifecycle (`waitingRoom`, `creation`, `optimization`, `waitingTraffic`). * [Handling large volumes](/guides/handling-large-volumes) — practical guidance for sizing `maxOptimizationDuration` and paginating large result sets.