Skip to main content
A device may already have a non-terminal action attached when your push lands. The onConflict field decides what happens when the new push collides with the existing one. Two strategies are accepted. Omit the field to get a 409 you can act on.

The two strategies

{
  "action": { "command": "charge", "parameters": { "power": { "value": 5, "unit": "kw" } }, "start": "2026-06-01T00:30:00", "end": "2026-06-01T05:30:00" },
  "onConflict": "cancel_and_replace"
}
StrategyBehaviour
cancel_and_replaceCancel the conflicting action. Run the new one. The replaced action transitions to cancelled.
queue_afterDefer the new action’s start to the conflict’s end. The new action persists in scheduled.
queue_after requires the conflicting action to declare an end. A push with queue_after against an open-ended conflict is rejected.

What counts as a conflict

One action is active per device at a time. A non-terminal state is any of scheduled or acknowledged. Terminal states (completed, failed, cancelled) do not block new pushes. Any new push for a device that already holds a non-terminal action is a conflict, whether it names a different command or the same one again. Overlap in time is not part of the test: two scheduled discharges for the same device, even with disjoint time windows, will collide on submission.
Window-overlap predicates that allow disjoint schedules to coexist are forthcoming. The current model maintains a single-active-action invariant per device.

Without onConflict: 409

Submit a push without onConflict while a conflict exists, and the API returns:
{
  "success": false,
  "error": {
    "code": "CONFLICT",
    "message": "A conflicting action is already pending for this device. Provide onConflict to resolve automatically, or cancel the existing action.",
    "details": {
      "reason": "no_strategy_supplied",
      "conflictingActionIds": ["action_abc123"],
      "strategies": ["cancel_and_replace", "queue_after"]
    }
  },
  "meta": {
    "requestId": "req_8a2Bf3kP",
    "timestamp": "2026-06-01T10:30:00.000Z",
    "path": "/battery/device_abc123",
    "latencyMs": 18
  }
}
The response carries the conflict ID and the strategies you can use to resolve it. Re-submit with your chosen strategy in one round trip. conflictingActionIds is always an array, even for a single conflict. The details.reason discriminator names the specific case: no_strategy_supplied when you omitted onConflict, conflicting_action_in_progress when cancel_and_replace cannot cancel a conflict the OEM has already accepted, and conflicting_action_not_windowed when queue_after has no end to queue behind. cancel_and_replace resolves a conflict that is still scheduled. Once the conflicting action reaches acknowledged (the OEM has the command), whether it can be cancelled depends on how long it has held that state. A genuinely in-progress action, acknowledged within the last 15 minutes, cannot be cancelled, and the 409 carries reason: "conflicting_action_in_progress":
{
  "success": false,
  "error": {
    "code": "CONFLICT",
    "message": "The conflicting action is already in progress and cannot be cancelled. Wait for it to complete or fail.",
    "details": {
      "reason": "conflicting_action_in_progress",
      "conflictingActionIds": ["action_abc123"]
    }
  },
  "meta": {
    "requestId": "req_8a2Bf3kP",
    "timestamp": "2026-06-01T10:30:00.000Z",
    "path": "/battery/device_abc123",
    "latencyMs": 18
  }
}
Poll the in-flight action and re-submit once it reaches a terminal state (completed, failed, or cancelled). An action that sits in acknowledged for more than 15 minutes is treated as abandoned: its completion never arrived, yet it would otherwise wedge every subsequent push for the device. The next cancel_and_replace reclaims it, marking the stranded action failed and proceeding with the new push, so a device cannot stay stuck behind a dead action. The 15-minute threshold sits comfortably above any legitimate dispatch-to-completion latency, so a genuinely in-progress action is never reclaimed early.

Foreign state: SCHEDULER_ACTIVE

A second class of conflict lives on the OEM side. Many batteries have a native scheduler that can hold rows from the homeowner’s mobile app, a third-party optimiser, or a previous Amps action that is no longer tracked. Before writing a windowed-mode push to such a device, Amps runs a foreign-state preflight.
{
  "success": false,
  "error": {
    "code": "SCHEDULER_ACTIVE",
    "message": "A schedule is currently active on the device and must be cleared first.",
    "details": {
      "reason": "foreign_scheduler_window_overlap",
      "description": "A schedule not created by Amps (homeowner app, external automation, or a previously-set Amps schedule whose action is terminal) overlaps this windowed write.",
      "existingGroupCount": 2,
      "overlappingGroups": [
        {
          "workMode": "ForceCharge",
          "startHour": 0,
          "startMinute": 0,
          "endHour": 6,
          "endMinute": 0
        }
      ],
      "recoveryStrategies": ["cancel_and_replace", "manual_clear_via_device_app"],
      "detectedAt": "submission_time"
    }
  },
  "meta": {
    "requestId": "req_8a2Bf3kP",
    "timestamp": "2026-06-01T10:30:00.000Z",
    "path": "/battery/device_abc123",
    "latencyMs": 22
  }
}
The reason field distinguishes:
reasonTrigger
foreign_scheduler_window_overlapForeign scheduler has rows that overlap the candidate window.
foreign_scheduler_blocking_imperativeForeign scheduler is actively running and the OEM blocks an imperative write while it runs.
foreign_scheduler_blocking_settings_writeForeign scheduler is active and the OEM blocks a settings write while it runs.
SCHEDULER_ACTIVE is the canonical name for any device-side conflict, distinct from CONFLICT, which names Amps-tracked conflicts. Both are 409. The recoveryStrategies array tells you what to try next: cancel_and_replace invokes the slot-aware merge (drop only overlapping rows, preserve the rest) on the OEM, while manual_clear_via_device_app is the escape hatch when the foreign owner is the homeowner and Amps cannot safely overwrite their schedule.

Slot-aware merge under cancel_and_replace

cancel_and_replace drops only overlapping rows on the device-side scheduler. Non-overlapping foreign rows are preserved. If the merged set would exceed the device’s slot limit, the API refuses with SCHEDULER_FULL carrying details.existingGroupCount and details.maxGroupCount.

Cleanup ownership

Recurring slots carry an ownership marker so Amps can clean them up at end-of-window without disturbing rows authored by other systems. Cleanup is idempotent and retries on transient failures.
If the homeowner or another optimiser alters the same scheduler outside Amps, cleanup no-ops safely. Amps only touches rows it owns and that overlap.

Detection timing

Foreign-state checks run twice on OEMs that expose a foreign-state read:
  1. Submission-time preflight. When you submit a windowed-mode push without cancel_and_replace, the API checks the OEM’s current scheduler state and refuses with 409 SCHEDULER_ACTIVE if there is overlap. details.detectedAt: 'submission_time'.
  2. Dispatch-time merge. When the scheduled fire moment arrives, the platform reads the OEM’s current state again and computes overlap. Foreign state can change between submission and dispatch, so the merge runs the same logic with details.detectedAt: 'dispatch_time'.
Preflight failures (network, auth, foreign-state read unavailable) do not block submission. The dispatch-time merge is the safety net.

Time and timezone constraints

The canonical contract is plant-local wall-clock: start and end are submitted as YYYY-MM-DDTHH:MM:SS (no offset, no Z) and the platform interprets them in the device’s plant timezone. ISO strings carrying any offset are rejected at the boundary with INVALID_REQUEST_BODY and a clear pointer at the wall-clock format. Relative durations (30m, 1.5h) remain valid on start only; end must always be an absolute wall-clock.

Frequently asked questions

When does the API return 409 CONFLICT?

When a new push lands on a device that already has a non-terminal action (scheduled or acknowledged) and the request body omits onConflict. The 409 carries conflictingActionIds[] and the strategies the platform supports for resolution. Submit again with onConflict set to one of those strategies and the platform applies it. Terminal actions (completed, failed, cancelled) do not block new pushes.

What is the difference between cancel_and_replace and queue_after?

cancel_and_replace cancels the conflicting action and runs the new one immediately. The replaced action transitions to cancelled. queue_after defers the new action’s start to the conflict’s end and persists it in scheduled. queue_after requires the conflicting action to declare an end; against an open-ended conflict (e.g., an auto.balanced with no end) the API rejects.

How do OEM-side scheduler conflicts surface?

As 409 SCHEDULER_ACTIVE with details.reason naming one of three triggers: foreign_scheduler_window_overlap (foreign rows overlap the candidate window), foreign_scheduler_blocking_imperative (the OEM blocks an imperative write while a foreign program runs), or foreign_scheduler_blocking_settings_write (the OEM blocks a settings write while a foreign program runs). The response carries recoveryStrategies (typically cancel_and_replace, manual_clear_via_device_app).

Does cancel_and_replace wipe the entire OEM scheduler?

No. cancel_and_replace drops only overlapping rows. Non-overlapping foreign rows are preserved. Rows authored by Amps carry an ownership marker; cleanup removes only those.

What if my windowed push hits the device’s slot limit?

The API returns SCHEDULER_FULL with details.existingGroupCount and details.maxGroupCount. The exact limit varies by device. Amps does not auto-prune to make space. The caller decides: cancel an existing schedule, ask the homeowner to clear rows from the OEM app, or accept the rejection.

Canonical Actions

The shape that carries onConflict.

Scheduling

Where conflicts arise: lifecycle, slots, recurrence.

Error Envelope

CONFLICT, SCHEDULER_ACTIVE, SCHEDULER_FULL, INVALID_TIME_WINDOW.

Capabilities

Which strategies a device exposes.
For a worked walkthrough of resolving a conflict in code, see the cookbook: handle a 409 CONFLICT.