Shop Commander · Developers

AI-Agent Guide

One page a coding agent needs to build a correct Shop Commander integration — paste it into your agent's context.

Paste this page into a coding agent, together with the shop's API key held in an environment variable, and it has what it needs.

Spec: https://shopcommander.com/developers/openapi.json (OpenAPI 3.1, exact and versioned). Base URL: https://api.shopcommander.com/v1. Auth: header Authorization: Bearer $SC_API_KEY (keys start sc_live_). No cookies, no query-string keys. First call: GET /v1/shoptimezone, currency, capabilities (feature flags). Adapt to them.

Rules

  1. Ids are typed strings (cus_…, veh_…, appt_…, ro_…, job_…, inv_…, pay_…, item_…, task_…). Never construct or parse them.
  2. Money is a decimal string with a currency. Use decimal arithmetic. null on a money field means "not visible to your scopes", not zero.
  3. Absolute request instants require RFC3339 Z or an explicit offset; naive datetimes are rejected. Appointments are wall-clock date + exact HH:MM in the shop's IANA timezone; DST gaps are rejected and folds require utc_offset. Date-only/business-date values are YYYY-MM-DD.
  4. Lists: {object:"list", data, has_more, next_cursor}; page with cursor=, limit ≤ 200. Sync with updated_after + sort=updated_at, overlapping by a minute and de-duplicating on id.
  5. PATCH presence matters. Omitted means unchanged; explicit null clears only documented nullable fields; false, 0, [], and "" are values. Unknown fields are rejected.
  6. Retries: use a fresh Idempotency-Key for every command. It is required for consequential creates/actions, replayable for seven days, and reserved for 30. Same key + different operation/target/body → 409.
  7. Concurrency: read version, send expected_version on updates; 409 version_conflict → re-read and merge.
  8. Errors: always {"error": {type, code, message, request_id, param?, details?, blockers?}}. Branch on code; body validation is 422 validation_failed. A 409 with blockers[] is a business rule — show blockers[].message / resolution to a person; do not retry. Check available_actions on appointments and jobs before acting.
  9. Rate limits: 300/min sustained, 60/10 s burst, 60 writes/min per integration; obey Retry-After on 429.
  10. Webhooks: at-least-once, unordered, thin payloads. Verify ShopCommander-Signature (t=…,v1=HMAC-SHA256(secret, "{t}.{raw_body}"), reject if |now−t|>300 s, accept if any v1 matches), de-duplicate on event id, then GET the object. Respond 2xx within 10 s.
  11. Out of scope by design: customer authorization of estimates, customer messaging, financial writes (posting invoices / recording payments), employee/HR data. Do not try to emulate them.
  12. Tenancy: one key = one shop. A record from another shop is a 404.

Resources (v1)

shop · customers (+ /archive) · vehicles · appointments (+ /availability, /cancel, /check-in) · repair-orders (+ paginated /jobs) · jobs (+ paginated /parts and /labor, /start, /complete, /unable-to-complete) · technicians · canned-jobs · inventory-items · invoices · payments · tasks (+ /complete) · external-id (PUT/DELETE on customers/vehicles/repair-orders/tasks) · webhook-endpoints (+ /rotate-secret, /test; secrets are shown only on create/rotate) · events.

Scopes gate each family: shop.read, customers.read|write, vehicles.read|write, appointments.read|write, repair_orders.read|write, jobs.actions, pricing.read, inventory.read, inventory.cost.read, canned_jobs.read, technicians.read, invoices.read, payments.read, tasks.read|write, webhooks.manage, external_refs.write. Write does not imply read; full-resource mutations require both. external_refs.write also requires the target resource's read scope. A missing scope is 403 insufficient_scope with the complete requirement in details.

Minimal client skeleton (Python)

import os, time, uuid, requests

BASE = "https://api.shopcommander.com/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['SC_API_KEY']}"

def call(method, path, *, json=None, params=None, idem=None):
    headers = {"Idempotency-Key": idem} if idem else {}
    for attempt in range(5):
        r = S.request(method, BASE + path, json=json, params=params, headers=headers, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "1"))); continue
        if r.status_code >= 500:
            time.sleep(2 ** attempt); continue
        if r.status_code >= 400:
            err = r.json()["error"]
            raise RuntimeError(f"{err['code']}: {err['message']} (request {err['request_id']}) blockers={err.get('blockers')}")
        return r.json()
    raise RuntimeError("gave up")

def each(path, **params):
    cursor = None
    while True:
        page = call("GET", path, params={**params, "limit": 200, **({"cursor": cursor} if cursor else {})})
        yield from page["data"]
        if not page["has_more"]:
            return
        cursor = page["next_cursor"]

shop = call("GET", "/shop")
appt = call("POST", "/appointments", idem=str(uuid.uuid4()),
            json={"customer_id": "cus_…", "vehicle_id": "veh_…", "date": "2026-09-14", "time": "09:30"})