{
  "openapi": "3.0.1",
  "info": {
    "title": "Flex API",
    "description": "Flex's tenant API for integrations and AI agents. Every response is scoped to the organization that owns the presented key.\n\n**Authentication.** Send `Authorization: Bearer flx_live_…` on every request. Keys are created by an organization administrator inside Flex; the secret is shown once and stored hashed. A key belongs to exactly one organization.\n\n**Permissions.** A key is granted a subset of Flex permissions when it is created. Each endpoint states the permission it needs; a key without it receives 403. `GET /api/v1/modules` lists the permissions of the presented key.\n\n**Modules.** Endpoints that belong to an optional Flex module (scheduling, invoicing, …) answer 403 with a problem detail when the organization has not activated that module. `GET /api/v1/modules` shows which modules are active.\n\n**Rate limits.** Fixed one-minute windows, per key (default 120 requests/min, configurable per key) and per organization (600/min). Exceeding either returns 429 with a `Retry-After` header (seconds).\n\n**Pagination.** List endpoints return `{ \"items\": [...], \"nextCursor\": \"…\" }`. Pass `after=<nextCursor>` to fetch the next page and `limit` to size it (1–200, default 50). `nextCursor` is null on the last page; cursors are opaque and stable.\n\n**ETags.** Single-resource GETs return a weak `ETag`. Send it back as `If-None-Match` to receive 304 Not Modified with no body when nothing changed.\n\n**Idempotency.** Send an `Idempotency-Key` header (any unique string, up to 128 characters) on POST, PUT, PATCH and DELETE. Replaying the same key with the same body returns the original response instead of repeating the write; the same key with a different body is rejected with 422. Keys are remembered for 24 hours per organization.\n\n**Errors.** Failures are RFC 7807 `application/problem+json`: 400 validation errors list per-field messages under `errors`; 401/403/404/409/422/429/500 carry `title`, `detail` and a `traceId` to quote in support requests.",
    "version": "v1"
  },
  "paths": {
    "/api/v1/customers": {
      "get": {
        "tags": [
          "Customers"
        ],
        "summary": "Lists customers, oldest first, one page at a time.",
        "description": "Cursor pagination: pass the `nextCursor` of the previous response as\n`after`. Filters combine with AND. Example:\n`GET /api/v1/customers?isActive=true&updatedSince=2026-01-01T00:00:00Z&limit=100`.",
        "parameters": [
          {
            "name": "after",
            "in": "query",
            "description": "SyncId of the last item of the previous page; omit for the first page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1–200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "updatedSince",
            "in": "query",
            "description": "Only customers created or edited at/after this UTC instant (ISO-8601, e.g. `2026-01-01T00:00:00Z`).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "isActive",
            "in": "query",
            "description": "Only active (`true`) or deactivated (`false`) customers; omit for both.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "customerType",
            "in": "query",
            "description": "Only this type: `EndCustomer`, `Installer`, `Distributor` or `Commercial`.",
            "schema": {
              "$ref": "#/components/schemas/CustomerType"
            }
          },
          {
            "name": "status",
            "in": "query",
            "description": "Only this status flag: `None`, `Preferred` or `Difficult`.",
            "schema": {
              "$ref": "#/components/schemas/CustomerStatus"
            }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Case-insensitive substring matched against name, company name and e-mail.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "A page of customers.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfCustomerDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed `after` cursor or filter value (validation problem listing the field).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Customers"
        ],
        "summary": "Creates a customer.",
        "description": "Requires `ManageCustomers` and an `Idempotency-Key` header (unique per attempt;\na retry with the same key and body replays the first response). The customer is created\nactive with a zero balance; `country` defaults to `\"USA\"`; when any of\n`address`/`city`/`state`/`postalCode` is given, a \"Primary Address\"\nmailing location is created too (see `/customers/{id}/locations`). The write is\nattributed to the user who created the API key.",
        "requestBody": {
          "description": "Customer fields; only `name` is required.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "201": {
            "description": "The new customer; `Location` points at `/api/v1/customers/{id}`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomerDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body, unknown enum value, or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The key's creator no longer exists on this server, so the write cannot be attributed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Well-formed but invalid field values (problem lists the fields), or `Idempotency-Key` reused with a different body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "The customer could not be saved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/customers/{id}": {
      "get": {
        "tags": [
          "Customers"
        ],
        "summary": "Returns one customer.",
        "description": "Send the previous `ETag` as `If-None-Match` to receive `304 Not Modified` when unchanged.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Customer id from a list response.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The customer, with an `ETag` header.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomerDto"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the supplied `If-None-Match`."
          },
          "404": {
            "description": "No such customer in this organization (also for deleted customers).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      },
      "put": {
        "tags": [
          "Customers"
        ],
        "summary": "Replaces the editable fields of one customer.",
        "description": "Requires `ManageCustomers` and an `Idempotency-Key` header. This is a full\nupdate: every editable field takes the value in the body (omitted optional fields\nbecome null / defaults), so GET the customer first and send it back modified.\nOptional optimistic concurrency: send the `ETag` from that GET as `If-Match`\nto get `412` instead of overwriting a change made in the meantime.\nBalances, deletion state and audit fields cannot be changed here.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Customer id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "description": "The complete set of editable fields.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CustomerWriteRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The updated customer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomerDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body, unknown enum value, or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such customer in this organization (also for deleted customers).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "Concurrent edit detected, or the key's creator no longer exists on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "412": {
            "description": "`If-Match` does not match the customer's current `ETag`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Well-formed but invalid field values (problem lists the fields), or `Idempotency-Key` reused with a different body.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "The customer could not be saved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/customers/{id}/locations": {
      "get": {
        "tags": [
          "Customers"
        ],
        "summary": "Lists the additional locations (job sites / addresses) of one customer.",
        "description": "The customer's primary address is on the customer resource itself; this collection\nholds the extra sites jobs can be booked at. Cursor-paginated like every list.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Customer id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "after",
            "in": "query",
            "description": "SyncId of the last item of the previous page; omit for the first page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1–200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "isActive",
            "in": "query",
            "description": "Only active (`true`) or retired (`false`) locations; omit for both.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "A page of locations (may be empty).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfCustomerLocationDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed `after` cursor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such customer in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invoices": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Lists invoice headers.",
        "description": "Requires `ViewInvoices`. Cursor-paginated: pass the last invoice's `syncId`\nas `after`.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Only invoices in this status (name such as `Sent` or numeric value).",
            "schema": {
              "$ref": "#/components/schemas/InvoiceStatus"
            }
          },
          {
            "name": "jobId",
            "in": "query",
            "description": "Only invoices billing this job.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "customerId",
            "in": "query",
            "description": "Only invoices billed to this customer.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "updatedSince",
            "in": "query",
            "description": "Only invoices created or modified at/after this UTC instant.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "after",
            "in": "query",
            "description": "SyncId of the last invoice of the previous page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1..200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "One page of invoice headers.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfInvoiceSummary"
                }
              }
            }
          },
          "400": {
            "description": "Unknown `after` cursor or unrecognized `status`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invoices/{id}": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "Returns one invoice with its lines and payments.",
        "description": "Requires `ViewInvoices`. `id` may be the numeric id or the `syncId`.\nSends a weak `ETag`; repeat with `If-None-Match` to get 304 when unchanged.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Numeric id or SyncId.",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The invoice.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceDetail"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the supplied ETag."
          },
          "404": {
            "description": "No such invoice in the calling organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invoices/{id}/mark-sent": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Finalizes a draft invoice (marks it sent).",
        "description": "Requires `ManageInvoices` and an `Idempotency-Key` header. Freezes the\ndraft's lines, records the send and moves it to `Sent` — the same step as the\n\"Finalize\" button, minus the customer email (send that through your own channel using\n`GET /api/v1/invoices/{id}`). Calling it on an already-finalized invoice only\nrefreshes its audit fields and returns it unchanged.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Numeric invoice id.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The invoice after finalization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceSummary"
                }
              }
            }
          },
          "404": {
            "description": "No such invoice in the calling organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The invoice is voided or was consolidated into another invoice, or the key's creator is unknown on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/invoices/from-job": {
      "post": {
        "tags": [
          "Invoices"
        ],
        "summary": "Creates a draft invoice for a job.",
        "description": "Requires `ManageInvoices` and an `Idempotency-Key` header. Bills the job's\nfull price — or, once a finalized invoice exists, only its unbilled remainder (a\nsupplemental invoice). The job's down payment transfers onto the invoice; store credit\nis applied when requested. Every omitted field defaults exactly as the Generate Invoice\npage does. The result is a `Draft`: finalize it with\n`POST /api/v1/invoices/{id}/mark-sent`.",
        "requestBody": {
          "description": "What to bill and, optionally, how.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateInvoiceFromJobRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateInvoiceFromJobRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CreateInvoiceFromJobRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "201": {
            "description": "The new draft invoice; `Location` points at it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvoiceSummary"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body or out-of-range value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such job in the calling organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The job cannot be invoiced (cancelled, estimate option not chosen), a draft is already pending, the invoice number is taken, or the key's creator is unknown on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Nothing left to bill, or `dueDate` precedes `invoiceDate`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "500": {
            "description": "The invoice could not be saved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/items": {
      "get": {
        "tags": [
          "Items"
        ],
        "summary": "Lists items (templates) with pricing and total on-hand.",
        "description": "Requires `ViewInventory`. Draft and deleted templates are excluded.\nCursor-paginated: pass the last item's `syncId` as `after`.",
        "parameters": [
          {
            "name": "search",
            "in": "query",
            "description": "Case-insensitive match against name and description.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "categoryId",
            "in": "query",
            "description": "Only items in this category (primary or assigned).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "updatedSince",
            "in": "query",
            "description": "Only items created or updated at/after this UTC instant.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "after",
            "in": "query",
            "description": "SyncId of the last item of the previous page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1..200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "One page of items.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfItemSummary"
                }
              }
            }
          },
          "400": {
            "description": "Unknown `after` cursor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/items/{id}": {
      "get": {
        "tags": [
          "Items"
        ],
        "summary": "Returns one item with its properties, price tiers, barcodes and per-location stock.",
        "description": "Requires `ViewInventory`. `id` may be the numeric id or the `syncId`.\nSends a weak `ETag`; repeat with `If-None-Match` to get 304 when unchanged.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Numeric id or SyncId.",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The item.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ItemDetail"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the supplied ETag."
          },
          "404": {
            "description": "No such item in the calling organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Lists the organization's jobs, oldest first, one page at a time.",
        "description": "Pagination: pass the previous response's `nextCursor` as `after`; `limit`\ndefaults to 50 and is capped at 200. All filters combine with AND. Response carries a\nweak `ETag`; resend it as `If-None-Match` to get 304 when nothing changed.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Workflow status name to match exactly (case-insensitive), e.g. `Scheduled`, `InProgress`, `Completed`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "customerId",
            "in": "query",
            "description": "Only jobs for this customer id.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "updatedSince",
            "in": "query",
            "description": "Only jobs created or modified at or after this instant (ISO-8601 UTC, e.g. `2026-09-01T00:00:00Z`). Use it to poll for changes.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "scheduledFrom",
            "in": "query",
            "description": "Only jobs whose scheduled start is at or after this local wall-clock time (`yyyy-MM-ddTHH:mm:ss`, no zone suffix).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "scheduledTo",
            "in": "query",
            "description": "Only jobs whose scheduled start is at or before this local wall-clock time.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "after",
            "in": "query",
            "description": "Cursor from the previous page's `nextCursor` (a job SyncId). Omit for the first page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1–200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "A page of jobs and the cursor for the next page (null on the last page).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfJobSummaryDto"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the `If-None-Match` ETag."
          },
          "400": {
            "description": "Unknown `status` or unusable `after` cursor (field listed in `errors`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Creates a job (estimate or draft) for a customer, optionally with material, labor and custom-charge lines.",
        "description": "Same code path as the MVC \"Create job\" page. Requires an `Idempotency-Key`\nheader. Line totals are computed on the server from the lines you send. The job\nnumber is generated when omitted. Initial status: `Estimated`, or `Draft`\nwhen `isDraft` is true, unless `statusId` / `statusKey` names one of\nthe organization's statuses. The write is attributed to the user who created the key.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateJobApiRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateJobApiRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CreateJobApiRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "201": {
            "description": "The created job (same shape as `GET /api/v1/jobs/{id}`); `Location` points at it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobDetailDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body, missing `Idempotency-Key`, or both `statusId` and `statusKey` given.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The key's creator is unknown on this server, or the service refused the create.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Unknown customer, site address, status, item template, labor rate or task template for this organization (field listed in `errors`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}": {
      "get": {
        "tags": [
          "Jobs"
        ],
        "summary": "Returns one job with its site address, pricing, assignees, material and labor lines and note timeline.",
        "description": "Removed (soft-deleted) lines and assignments are excluded. Response carries a weak\n`ETag`; resend it as `If-None-Match` to get 304 when nothing changed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Job id from the list endpoint.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "The job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobDetailDto"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the `If-None-Match` ETag."
          },
          "404": {
            "description": "No such job in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}/assignees": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Assigns people to a job — additively by default, or as the complete new crew with `replace: true`.",
        "description": "Requires the Scheduling module. User ids come from `GET /api/v1/users` and must be\nactive members of the organization. People already on the job keep their crew\n(schedule group) context; newly added people are assigned as individuals. With\n`replace: true` anyone not listed is unassigned (an empty list clears the job).\nRequires an `Idempotency-Key` header.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Job id from a list response.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "description": "User ids to assign and whether they replace the current set.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AssignJobRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/AssignJobRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/AssignJobRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Scheduling module not active for this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Job id and a message listing who was assigned / unassigned (or \"Assignments unchanged.\").",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiOperationResponse"
                }
              }
            }
          },
          "400": {
            "description": "`userIds` missing, or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such job in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The key's creator is unknown on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "One or more `userIds` are not active members of this organization (listed in the message).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}/cancel": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Cancels a job, returning its materials to stock.",
        "description": "Same rules as the UI: a job that has received payment, or has sent/paid invoices,\ncannot be cancelled until the money is refunded / invoices voided (409). A job with\nan uncollected down payment can be cancelled with `issueStoreCredit: true`\nwhen the key holds `ManageStoreCredit`. Cancelling an already-cancelled job\nanswers 200 with a `warning`. Requires an `Idempotency-Key` header.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Job id from a list response.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "description": "Optional reason and the store-credit choice.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CancelJobRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/CancelJobRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/CancelJobRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Job id, confirmation message and an optional warning.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiOperationResponse"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such job in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "Payments or invoices block the cancellation, or the key's creator is unknown on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}/reschedule": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Moves a scheduled job to a new start time (and optionally a new end).",
        "description": "Requires the Scheduling module. Only jobs in `Scheduled` status — or draft /\nestimated jobs that already carry a scheduled date — can be rescheduled; use\n`POST /jobs/{id}/status` to schedule an unscheduled job first. Times are the\norganization's local wall clock (`yyyy-MM-ddTHH:mm:ss`); a trailing `Z`\nis converted to local time. Requires an `Idempotency-Key` header.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Job id from a list response.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "description": "New start, optional end and note.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RescheduleJobRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/RescheduleJobRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/RescheduleJobRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Scheduling module not active for this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Job id and confirmation message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiOperationResponse"
                }
              }
            }
          },
          "400": {
            "description": "`start` missing, `end` not after `start`, or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such job in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The job's status does not allow rescheduling, or the key's creator is unknown on this server.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{id}/status": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "summary": "Moves a job to another status.",
        "description": "Name the target with `statusId` (one of the organization's status definitions)\nor `statusKey` (a status definition name, or a system key such as\n`InProgress` / `Completed`), not both. No transition rules are enforced —\nany status may follow any other, exactly as in the UI. Requires an\n`Idempotency-Key` header.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Job id from a list response.",
            "required": true,
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "requestBody": {
          "description": "Target status and optional note.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetJobStatusRequest"
              }
            },
            "text/json": {
              "schema": {
                "$ref": "#/components/schemas/SetJobStatusRequest"
              }
            },
            "application/*+json": {
              "schema": {
                "$ref": "#/components/schemas/SetJobStatusRequest"
              }
            }
          }
        },
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Job id and confirmation message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiOperationResponse"
                }
              }
            }
          },
          "400": {
            "description": "Neither or both of `statusId` / `statusKey`, or missing `Idempotency-Key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          },
          "404": {
            "description": "No such job in this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "409": {
            "description": "The key's creator is unknown on this server, or the service refused the change.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "422": {
            "description": "Unknown status for this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/modules": {
      "get": {
        "tags": [
          "Modules"
        ],
        "summary": "Lists the calling organization's modules and the presented key's permissions.",
        "description": "Requires a valid API key but no specific permission. Module gating on other\nendpoints follows the `active` flag returned here.",
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Forbidden",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Modules with activation state, plus the key's permissions.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModulesResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/schedule": {
      "get": {
        "tags": [
          "Schedule"
        ],
        "summary": "Lists the jobs scheduled to start within a date range as calendar events.",
        "description": "Times are the organization's local wall clock with no zone suffix\n(`2026-09-08T00:00:00`); a trailing `Z` is converted to local time first.\nThe window may span at most 92 days. Events are not paginated — a quarter of jobs\nis returned in one array. Response carries a weak `ETag`; resend it as\n`If-None-Match` to get 304 when nothing changed.",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "description": "Inclusive start of the window (required). Example: `2026-09-01T00:00:00`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "description": "Inclusive end of the window (required), at most 92 days after from. Example: `2026-09-30T23:59:59`.",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "Key lacks `ViewJobs` or the Scheduling module is not active.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "Calendar events ordered as the service returns them (by job).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ScheduleEventDto"
                  }
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the `If-None-Match` ETag."
          },
          "400": {
            "description": "Missing parameter, `to` before `from`, or a window longer than 92 days (field listed in `errors`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/schedule-groups": {
      "get": {
        "tags": [
          "ScheduleGroups"
        ],
        "summary": "Lists schedule groups with their active members, one page at a time.",
        "description": "Groups are flat with a `parentGroupId`; nest them client-side. Example:\n`GET /api/v1/schedule-groups?parentGroupId=3` returns the crews of division 3.",
        "parameters": [
          {
            "name": "after",
            "in": "query",
            "description": "SyncId of the last item of the previous page; omit for the first page.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Page size, 1–200 (default 50).",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          },
          {
            "name": "updatedSince",
            "in": "query",
            "description": "Only groups created or edited at/after this UTC instant (ISO-8601).",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "parentGroupId",
            "in": "query",
            "description": "Only direct children of this group; pass `0` for top-level groups only.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "The `scheduling` module is not active for this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "A page of groups.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfScheduleGroupDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed `after` cursor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/users": {
      "get": {
        "tags": [
          "Users"
        ],
        "summary": "Lists the organization's users with the schedule groups each belongs to.",
        "description": "Users are keyed by string ids, so this collection is <b>not</b> cursor-paginated: the\nwhole list is returned in one response (ordered by first then last name) with\n`nextCursor` always `null`. The response carries an `ETag`; send it as\n`If-None-Match` to get `304` when nothing changed. Example:\n`GET /api/v1/users?scheduleGroupId=5` returns the members of group 5.",
        "parameters": [
          {
            "name": "includeInactive",
            "in": "query",
            "description": "Also return deactivated accounts (default `false`).",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "scheduleGroupId",
            "in": "query",
            "description": "Only users who are members of this schedule group.",
            "schema": {
              "type": "integer",
              "format": "int32"
            }
          }
        ],
        "responses": {
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "403": {
            "description": "The `scheduling` module is not active for this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "429": {
            "description": "Too Many Requests",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "200": {
            "description": "All matching users.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PagedResponseOfUserSummaryDto"
                }
              }
            }
          },
          "304": {
            "description": "Unchanged since the supplied `If-None-Match`."
          },
          "404": {
            "description": "scheduleGroupId is not a group of this organization.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ApiOperationResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Id of the resource the operation acted on (e.g. the job id).",
            "format": "int32"
          },
          "message": {
            "type": "string",
            "description": "Human-readable confirmation from the service (e.g. \"Job started.\"). May be null.",
            "nullable": true
          },
          "warning": {
            "type": "string",
            "description": "Non-fatal problem encountered while completing the operation (e.g. a notification email that could not be sent). Null when there was none.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Body of a successful workflow-style write that has no richer resource to return\n(assign, start, complete, void…). Id is the affected resource's id;\nWarning carries a non-fatal note from the service (e.g. an email that\ncould not be sent)."
      },
      "AssignJobRequest": {
        "required": [
          "userIds"
        ],
        "type": "object",
        "properties": {
          "userIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Identity user ids (see `GET /api/v1/users`) to assign. All must be active members of the organization."
          },
          "replace": {
            "type": "boolean",
            "description": "`true`: the list becomes the complete set of assignees (people not listed are\n            unassigned; an empty list unassigns everyone). `false` (default): the listed\n            people are added to whoever is already assigned."
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/jobs/{id}/assignees`."
      },
      "CancelJobRequest": {
        "type": "object",
        "properties": {
          "reason": {
            "maxLength": 1000,
            "minLength": 0,
            "type": "string",
            "description": "Why the job is being cancelled (recorded on the job; \"No reason provided\" when omitted).",
            "nullable": true
          },
          "issueStoreCredit": {
            "type": "boolean",
            "description": "When the job holds an uncollected down payment, convert it to customer store credit\ninstead of blocking the cancellation. Requires the key to hold `ManageStoreCredit`."
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/jobs/{id}/cancel`."
      },
      "CreateInvoiceFromJobRequest": {
        "type": "object",
        "properties": {
          "jobId": {
            "maximum": 2147483647,
            "minimum": 1,
            "type": "integer",
            "description": "Numeric id of the job to bill.",
            "format": "int32"
          },
          "invoiceNumber": {
            "maxLength": 50,
            "minLength": 0,
            "type": "string",
            "description": "Invoice number. Defaults to `INV-{jobNumber}`, or `INV-{jobNumber}-{n}` for the n-th invoice on the job.",
            "nullable": true
          },
          "invoiceDate": {
            "type": "string",
            "description": "Issue date (UTC, date part only). Defaults to today.",
            "format": "date-time",
            "nullable": true
          },
          "dueDate": {
            "type": "string",
            "description": "Due date (UTC, date part only). Defaults from the customer's payment terms; must not precede `invoiceDate`.",
            "format": "date-time",
            "nullable": true
          },
          "taxRate": {
            "maximum": 100,
            "minimum": 0,
            "type": "number",
            "description": "Tax rate percentage. Defaults to the job's rate; always 0 for a tax-exempt customer.",
            "format": "double",
            "nullable": true
          },
          "terms": {
            "type": "string",
            "description": "Payment terms text printed on the invoice. Defaults to the customer's terms (e.g. \"Net 30\").",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Notes shown to the customer.",
            "nullable": true
          },
          "paymentInstructions": {
            "type": "string",
            "description": "Payment instructions printed on the invoice. Defaults to the organization's default instructions.",
            "nullable": true
          },
          "applyStoreCredit": {
            "type": "boolean",
            "description": "Apply `storeCreditToApply` of the customer's store credit to the new invoice."
          },
          "storeCreditToApply": {
            "minimum": 0,
            "type": "number",
            "description": "Store credit amount to apply when `applyStoreCredit` is true (capped at the customer's balance and the invoice balance). Ignored otherwise.",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/invoices/from-job`. Only `jobId` is required; every\nomitted field defaults exactly as the Generate Invoice page does (number\n`INV-{jobNumber}` or `INV-{jobNumber}-{n}`, today, due date from the customer's\npayment terms, the job's tax rate, the organization's payment instructions). Amounts are\nnever accepted: the subtotal and taxable portion come from the job — or, once a finalized\ninvoice exists, from its unbilled remainder (a supplemental invoice)."
      },
      "CreateJobApiRequest": {
        "required": [
          "customerId"
        ],
        "type": "object",
        "properties": {
          "customerId": {
            "maximum": 2147483647,
            "minimum": 1,
            "type": "integer",
            "description": "Customer the job is for (must belong to the key's organization).",
            "format": "int32"
          },
          "customerAddressId": {
            "type": "integer",
            "description": "Optional site address id; must belong to `customerId`.",
            "format": "int32",
            "nullable": true
          },
          "jobNumber": {
            "maxLength": 50,
            "minLength": 0,
            "type": "string",
            "description": "Optional job number. Omit to have one generated; a duplicate is suffixed to stay unique.",
            "nullable": true
          },
          "jobType": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Free-text job type, e.g. `Installation` (default), `Repair`, `Service`."
          },
          "description": {
            "maxLength": 500,
            "minLength": 0,
            "type": "string",
            "description": "Short description of the work, shown in lists. ≤ 500 characters. Example: `Replace water heater`.",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Free-form internal notes (not shown to the customer). No length limit.",
            "nullable": true
          },
          "isDraft": {
            "type": "boolean",
            "description": "Save as a draft (status `Draft`) instead of an estimate. Ignored when\n`statusId` / `statusKey` is given."
          },
          "statusId": {
            "type": "integer",
            "description": "Initial status definition id (one of the org's statuses). Mutually exclusive with `statusKey`.",
            "format": "int32",
            "nullable": true
          },
          "statusKey": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Initial status by name — a status definition name (case-insensitive) or a system\nstatus key such as `Draft`, `Estimated`, `Scheduled`. Mutually\nexclusive with `statusId`.",
            "nullable": true
          },
          "scheduledDate": {
            "type": "string",
            "description": "Scheduled start as the organization's local wall clock, `yyyy-MM-ddTHH:mm:ss` with no UTC offset (the `date-time` format here is a local time, not RFC 3339 with zone); a trailing `Z` is converted to local time. Example: `2026-09-08T08:30:00`.",
            "format": "date-time",
            "nullable": true
          },
          "estimateChecklistTemplateId": {
            "type": "integer",
            "description": "Optional id of one of the organization's estimate pre-inspection checklist templates; its sections and items are copied onto the job.",
            "format": "int32",
            "nullable": true
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreateJobItemApiRequest"
            },
            "description": "Material lines (item templates with quantity and pricing). May be empty."
          },
          "laborItems": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreateJobLaborApiRequest"
            },
            "description": "Labor lines (hours × rates). May be empty."
          },
          "customCharges": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreateJobCustomChargeApiRequest"
            },
            "description": "Custom charges (fees, disposal, permits …) with a price and optional cost. May be empty."
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/jobs` (API_AND_MCP.md, Phase 1b). Mirrors what the MVC\nCreate page posts, minus the browser-only fields; totals are computed server-side\nfrom the lines instead of being trusted from the client."
      },
      "CreateJobCustomChargeApiRequest": {
        "required": [
          "name"
        ],
        "type": "object",
        "properties": {
          "name": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "Charge name shown on the estimate, ≤ 200 characters. Required. Example: `Disposal fee`."
          },
          "description": {
            "maxLength": 500,
            "minLength": 0,
            "type": "string",
            "description": "Optional longer description, ≤ 500 characters.",
            "nullable": true
          },
          "price": {
            "minimum": 0,
            "type": "number",
            "description": "Amount charged to the customer, in the organization's currency, ≥ 0. Example: `75.00`.",
            "format": "double"
          },
          "cost": {
            "minimum": 0,
            "type": "number",
            "description": "Cost to the organization (for margin), in its currency, ≥ 0. Default 0.",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "description": "A custom charge on a new job."
      },
      "CreateJobItemApiRequest": {
        "type": "object",
        "properties": {
          "itemTemplateId": {
            "maximum": 2147483647,
            "minimum": 1,
            "type": "integer",
            "description": "Item template id from `GET /api/v1/items`; must belong to the key's organization.",
            "format": "int32"
          },
          "quantity": {
            "maximum": 2147483647,
            "minimum": 1,
            "type": "integer",
            "description": "Whole units, ≥ 1. Default 1.",
            "format": "int32"
          },
          "unitCost": {
            "minimum": 0,
            "type": "number",
            "description": "Cost per unit to the organization, in its currency, ≥ 0. Example: `12.50`.",
            "format": "double"
          },
          "unitPrice": {
            "minimum": 0,
            "type": "number",
            "description": "Price per unit charged to the customer, in the organization's currency, ≥ 0. Example: `19.99`.",
            "format": "double"
          },
          "discountPercent": {
            "maximum": 100,
            "minimum": 0,
            "type": "number",
            "description": "Line discount as a percentage, 0–100 (`10` = 10 % off `quantity` × `unitPrice`). Default 0.",
            "format": "double"
          },
          "notes": {
            "maxLength": 500,
            "minLength": 0,
            "type": "string",
            "description": "Line note, ≤ 500 characters.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A material line on a new job."
      },
      "CreateJobLaborApiRequest": {
        "required": [
          "description"
        ],
        "type": "object",
        "properties": {
          "description": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "What the labor is, ≤ 200 characters. Required. Example: `Install and commission unit`."
          },
          "taskTemplateId": {
            "type": "integer",
            "description": "Optional id of the organization's task template this line derives from.",
            "format": "int32",
            "nullable": true
          },
          "laborRateId": {
            "type": "integer",
            "description": "Optional id of the organization's labor rate the hourly figures were taken from (the figures themselves are still sent explicitly).",
            "format": "int32",
            "nullable": true
          },
          "estimatedHours": {
            "minimum": 0,
            "type": "number",
            "description": "Estimated hours, decimal ≥ 0. Example: `2.5`.",
            "format": "double"
          },
          "customerRate": {
            "minimum": 0,
            "type": "number",
            "description": "Hourly rate billed to the customer.",
            "format": "double"
          },
          "employeeCost": {
            "minimum": 0,
            "type": "number",
            "description": "Hourly cost of the technician.",
            "format": "double"
          },
          "overheadFactor": {
            "minimum": 0,
            "type": "number",
            "description": "Multiplier applied to `employeeCost` for overhead (default 1).",
            "format": "double"
          },
          "notes": {
            "maxLength": 500,
            "minLength": 0,
            "type": "string",
            "description": "Line note, ≤ 500 characters.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A labor line on a new job."
      },
      "CustomerDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id used in URLs (e.g. `/api/v1/customers/42`). Stable within this organization.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Globally unique sync id (GUID). Use it as the `after` pagination cursor and when correlating with offline devices.",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Display name, e.g. `\"Jane Doe\"`. Always present."
          },
          "companyName": {
            "type": "string",
            "description": "Company / trading name when the customer is a business, e.g. `\"Doe Roofing LLC\"`.",
            "nullable": true
          },
          "customerType": {
            "type": "string",
            "description": "Customer type: `EndCustomer`, `Installer`, `Distributor` or `Commercial`."
          },
          "primaryContact": {
            "type": "string",
            "description": "Name of the primary contact person, if different from `name`.",
            "nullable": true
          },
          "email": {
            "type": "string",
            "description": "Contact e-mail address.",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "description": "Main phone number as entered (no normalisation).",
            "nullable": true
          },
          "mobilePhone": {
            "type": "string",
            "description": "Mobile phone number as entered.",
            "nullable": true
          },
          "address": {
            "type": "string",
            "description": "Primary (billing) street address line 1.",
            "nullable": true
          },
          "address2": {
            "type": "string",
            "description": "Primary address line 2 (suite, unit).",
            "nullable": true
          },
          "city": {
            "type": "string",
            "description": "Primary address city.",
            "nullable": true
          },
          "state": {
            "type": "string",
            "description": "Primary address state / province.",
            "nullable": true
          },
          "postalCode": {
            "type": "string",
            "description": "Primary address ZIP / postal code.",
            "nullable": true
          },
          "country": {
            "type": "string",
            "description": "Primary address country.",
            "nullable": true
          },
          "isTaxExempt": {
            "type": "boolean",
            "description": "True when the customer is tax exempt; null when not recorded.",
            "nullable": true
          },
          "priceTier": {
            "type": "string",
            "description": "Price tier applied to this customer: `Standard`, `Silver`, `Gold`, `Platinum` or `Custom`."
          },
          "paymentTerms": {
            "type": "string",
            "description": "Default payment terms: `DueOnReceipt`, `Net15`, `Net30`, `Net45`, `Net60`, `COD` or `Prepaid`."
          },
          "creditLimit": {
            "type": "number",
            "description": "Credit limit in the organization's currency, e.g. `5000.00`.",
            "format": "double"
          },
          "currentBalance": {
            "type": "number",
            "description": "Outstanding balance owed by the customer, e.g. `1250.50`.",
            "format": "double"
          },
          "storeCreditBalance": {
            "type": "number",
            "description": "Store credit available to the customer, e.g. `0.00`.",
            "format": "double"
          },
          "status": {
            "type": "string",
            "description": "Status flag: `None`, `Preferred` or `Difficult`."
          },
          "taxId": {
            "type": "string",
            "description": "Tax identification number, if recorded.",
            "nullable": true
          },
          "warningNotes": {
            "type": "string",
            "description": "Warning shown to staff when the customer is selected (e.g. `\"Pays late\"`).",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Free-form internal notes.",
            "nullable": true
          },
          "isActive": {
            "type": "boolean",
            "description": "False when the customer has been deactivated (kept for history, hidden from pickers)."
          },
          "createdAt": {
            "type": "string",
            "description": "UTC creation timestamp, e.g. `2026-03-14T09:12:00Z`.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "UTC timestamp of the last edit; null when never edited after creation.",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A customer of the calling organization as returned by `GET /api/v1/customers` and\n`GET /api/v1/customers/{id}`. Money values are decimals in the organization's\ncurrency; dates are ISO-8601 UTC. Additional job-site addresses are a separate\ncollection at `/api/v1/customers/{id}/locations`."
      },
      "CustomerLocationDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id of the location. Stable within this organization.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Globally unique sync id (GUID). Use it as the `after` pagination cursor.",
            "format": "uuid"
          },
          "customerId": {
            "type": "integer",
            "description": "Id of the owning customer (matches the `{id}` in the URL).",
            "format": "int32"
          },
          "name": {
            "type": "string",
            "description": "Label given to the location, e.g. `\"Main Office\"` or `\"Rental Property 1\"`."
          },
          "address": {
            "type": "string",
            "description": "Street address line 1.",
            "nullable": true
          },
          "address2": {
            "type": "string",
            "description": "Street address line 2 (suite, unit).",
            "nullable": true
          },
          "city": {
            "type": "string",
            "description": "City.",
            "nullable": true
          },
          "state": {
            "type": "string",
            "description": "State / province.",
            "nullable": true
          },
          "postalCode": {
            "type": "string",
            "description": "ZIP / postal code.",
            "nullable": true
          },
          "country": {
            "type": "string",
            "description": "Country.",
            "nullable": true
          },
          "isMailingAddress": {
            "type": "boolean",
            "description": "True when this is the customer's mailing address."
          },
          "isActive": {
            "type": "boolean",
            "description": "False when the location has been retired and should not be offered for new jobs."
          },
          "notes": {
            "type": "string",
            "description": "Free-text notes about the site (gate codes, parking), max 500 characters.",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "description": "UTC creation timestamp.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "UTC timestamp of the last edit; null when never edited.",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "An additional address / job site belonging to a customer, as returned by\n`GET /api/v1/customers/{id}/locations`. Jobs can reference one of these as the\nsite to work at; the customer's primary address is on the customer itself."
      },
      "CustomerStatus": {
        "enum": [
          "None",
          "Preferred",
          "Difficult"
        ],
        "type": "string"
      },
      "CustomerType": {
        "enum": [
          "EndCustomer",
          "Installer",
          "Distributor",
          "Commercial"
        ],
        "type": "string"
      },
      "CustomerWriteRequest": {
        "required": [
          "name"
        ],
        "type": "object",
        "properties": {
          "name": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "Display name, e.g. `\"Jane Doe\"`. Required, ≤ 200 characters."
          },
          "companyName": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "Company / trading name, e.g. `\"Doe Roofing LLC\"`. ≤ 200.",
            "nullable": true
          },
          "customerType": {
            "$ref": "#/components/schemas/CustomerType"
          },
          "primaryContact": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Primary contact person, if different from `name`. ≤ 100.",
            "nullable": true
          },
          "email": {
            "maxLength": 256,
            "minLength": 0,
            "type": "string",
            "description": "Contact e-mail address. ≤ 256.",
            "format": "email",
            "nullable": true
          },
          "phone": {
            "maxLength": 20,
            "minLength": 0,
            "type": "string",
            "description": "Main phone number, stored as entered. ≤ 20.",
            "format": "tel",
            "nullable": true
          },
          "mobilePhone": {
            "maxLength": 20,
            "minLength": 0,
            "type": "string",
            "description": "Mobile phone number, stored as entered. ≤ 20.",
            "format": "tel",
            "nullable": true
          },
          "address": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "Primary (billing) street address line 1. ≤ 200. On create, any of address/city/state/postalCode also creates a \"Primary Address\" location.",
            "nullable": true
          },
          "address2": {
            "maxLength": 200,
            "minLength": 0,
            "type": "string",
            "description": "Primary address line 2 (suite, unit). ≤ 200.",
            "nullable": true
          },
          "city": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Primary address city. ≤ 100.",
            "nullable": true
          },
          "state": {
            "maxLength": 50,
            "minLength": 0,
            "type": "string",
            "description": "Primary address state / province. ≤ 50.",
            "nullable": true
          },
          "postalCode": {
            "maxLength": 20,
            "minLength": 0,
            "type": "string",
            "description": "Primary address ZIP / postal code. ≤ 20.",
            "nullable": true
          },
          "country": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Primary address country. ≤ 100. Defaults to `\"USA\"` on create.",
            "nullable": true
          },
          "taxId": {
            "maxLength": 50,
            "minLength": 0,
            "type": "string",
            "description": "Tax identification number. ≤ 50.",
            "nullable": true
          },
          "isTaxExempt": {
            "type": "boolean",
            "description": "True when tax exempt; null when not recorded.",
            "nullable": true
          },
          "priceTier": {
            "$ref": "#/components/schemas/PriceTier"
          },
          "paymentTerms": {
            "$ref": "#/components/schemas/PaymentTerms"
          },
          "creditLimit": {
            "type": "number",
            "description": "Credit limit in the organization's currency, e.g. `5000.00`. Default 0.",
            "format": "double"
          },
          "isActive": {
            "type": "boolean",
            "description": "Update only — false deactivates the customer (kept for history, hidden from pickers). Ignored on create; new customers are always active."
          },
          "status": {
            "$ref": "#/components/schemas/CustomerStatus"
          },
          "warningNotes": {
            "maxLength": 1000,
            "minLength": 0,
            "type": "string",
            "description": "Warning shown to staff when the customer is selected. ≤ 1000.",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Free-form internal notes.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/customers` and `PUT /api/v1/customers/{id}`. PUT replaces\nevery editable field, so send the current value of anything you want to keep; omitted\noptional fields become null. Enum fields accept their names (case-insensitive) or\nnumeric values. Balances (`currentBalance`, `storeCreditBalance`) are\nmaintained by invoicing and cannot be set here."
      },
      "InvoiceDetail": {
        "type": "object",
        "properties": {
          "invoice": {
            "$ref": "#/components/schemas/InvoiceSummary"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceLineDto"
            },
            "description": "Lines in display order."
          },
          "payments": {
            "$ref": "#/components/schemas/PaymentsSummary"
          },
          "terms": {
            "type": "string",
            "description": "Payment terms text.",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Invoice notes shown to the customer.",
            "nullable": true
          },
          "paymentInstructions": {
            "type": "string",
            "description": "Instructions printed on the invoice.",
            "nullable": true
          },
          "paymentLinkUrl": {
            "type": "string",
            "description": "Online payment link, if one was generated.",
            "nullable": true
          },
          "taxRate": {
            "type": "number",
            "description": "Tax rate applied (percentage).",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "description": "Full invoice as returned by `GET /api/v1/invoices/{id}`."
      },
      "InvoiceLineDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Line id.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device id.",
            "format": "uuid"
          },
          "description": {
            "type": "string",
            "description": "Line description."
          },
          "itemType": {
            "$ref": "#/components/schemas/InvoiceLineItemType"
          },
          "quantity": {
            "type": "number",
            "description": "Quantity billed.",
            "format": "double"
          },
          "unitOfMeasure": {
            "type": "string",
            "description": "Unit of measure, if any.",
            "nullable": true
          },
          "unitPrice": {
            "type": "number",
            "description": "Price per unit after discount.",
            "format": "double"
          },
          "originalUnitPrice": {
            "type": "number",
            "description": "Price per unit before discount, when a discount applied.",
            "format": "double",
            "nullable": true
          },
          "discountPercent": {
            "type": "number",
            "description": "Discount percentage applied to the line.",
            "format": "double"
          },
          "discountAmount": {
            "type": "number",
            "description": "Discount amount applied to the line.",
            "format": "double"
          },
          "total": {
            "type": "number",
            "description": "Quantity × unit price.",
            "format": "double"
          },
          "notes": {
            "type": "string",
            "description": "Line notes, if any.",
            "nullable": true
          },
          "sortOrder": {
            "type": "integer",
            "description": "Display order.",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "description": "One invoice line."
      },
      "InvoiceLineItemType": {
        "enum": [
          "Material",
          "Labor",
          "Service",
          "Other"
        ],
        "type": "string"
      },
      "InvoicePaymentDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Payment id.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device id.",
            "format": "uuid"
          },
          "amount": {
            "type": "number",
            "description": "Amount paid.",
            "format": "double"
          },
          "paymentDate": {
            "type": "string",
            "description": "Payment date, ISO-8601 UTC, e.g. `2026-09-05T14:30:00Z`.",
            "format": "date-time"
          },
          "paymentMethod": {
            "type": "string",
            "description": "Method (e.g. Cash, Card, Check), if recorded.",
            "nullable": true
          },
          "referenceNumber": {
            "type": "string",
            "description": "Check/transaction reference, if recorded.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A payment recorded against an invoice."
      },
      "InvoiceStatus": {
        "enum": [
          "Draft",
          "Sent",
          "Viewed",
          "PartiallyPaid",
          "Paid",
          "Overdue",
          "Cancelled",
          "Outstanding",
          "Consolidated"
        ],
        "type": "string"
      },
      "InvoiceSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id, usable in `/api/v1/invoices/{id}`.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device id; also the pagination cursor.",
            "format": "uuid"
          },
          "invoiceNumber": {
            "type": "string",
            "description": "Human-readable invoice number, e.g. `INV-1042`."
          },
          "status": {
            "$ref": "#/components/schemas/InvoiceStatus"
          },
          "customerId": {
            "type": "integer",
            "description": "Billed customer id, if any.",
            "format": "int32",
            "nullable": true
          },
          "customerName": {
            "type": "string",
            "description": "Billed customer name, if any.",
            "nullable": true
          },
          "jobId": {
            "type": "integer",
            "description": "Job the invoice bills, if any.",
            "format": "int32",
            "nullable": true
          },
          "jobNumber": {
            "type": "string",
            "description": "Job number, if any.",
            "nullable": true
          },
          "salesOrderId": {
            "type": "integer",
            "description": "Sales order the invoice bills, if any.",
            "format": "int32",
            "nullable": true
          },
          "description": {
            "type": "string",
            "description": "Optional description.",
            "nullable": true
          },
          "invoiceDate": {
            "type": "string",
            "description": "Issue date, ISO-8601 UTC at midnight, e.g. `2026-09-05T00:00:00Z`.",
            "format": "date-time"
          },
          "dueDate": {
            "type": "string",
            "description": "Due date, ISO-8601 UTC at midnight, e.g. `2026-10-05T00:00:00Z`.",
            "format": "date-time"
          },
          "subtotal": {
            "type": "number",
            "description": "Sum of lines before tax.",
            "format": "double"
          },
          "taxAmount": {
            "type": "number",
            "description": "Tax charged.",
            "format": "double"
          },
          "total": {
            "type": "number",
            "description": "Subtotal plus tax.",
            "format": "double"
          },
          "amountPaid": {
            "type": "number",
            "description": "Payments applied.",
            "format": "double"
          },
          "storeCreditApplied": {
            "type": "number",
            "description": "Store credit applied.",
            "format": "double"
          },
          "balanceDue": {
            "type": "number",
            "description": "Total minus payments and store credit.",
            "format": "double"
          },
          "isConsolidated": {
            "type": "boolean",
            "description": "True when this invoice consolidates other invoices."
          },
          "consolidatedIntoInvoiceId": {
            "type": "integer",
            "description": "Id of the consolidated invoice that absorbed this one, if any.",
            "format": "int32",
            "nullable": true
          },
          "sentDate": {
            "type": "string",
            "description": "When the invoice was first sent, ISO-8601 UTC (e.g. `2026-09-05T14:30:00Z`); null if never sent.",
            "format": "date-time",
            "nullable": true
          },
          "paidDate": {
            "type": "string",
            "description": "When the invoice was paid in full, ISO-8601 UTC; null until paid.",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "description": "Creation time, ISO-8601 UTC.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "Last modification time, ISO-8601 UTC; null when never edited. Use with the `updatedSince` filter to poll for changes.",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Invoice header as listed by `GET /api/v1/invoices`. Timestamps are ISO-8601 UTC (`2026-09-05T14:30:00Z`)."
      },
      "ItemBarcodeDto": {
        "type": "object",
        "properties": {
          "value": {
            "type": "string",
            "description": "Encoded value."
          },
          "type": {
            "type": "string",
            "description": "Symbology (e.g. `UPC`, `Code128`)."
          },
          "isPrimary": {
            "type": "boolean",
            "description": "True for the barcode printed on labels."
          }
        },
        "additionalProperties": false,
        "description": "A barcode attached to an item."
      },
      "ItemDetail": {
        "type": "object",
        "properties": {
          "item": {
            "$ref": "#/components/schemas/ItemSummary"
          },
          "properties": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Every template property by name (includes `Unit Price` and `Cost` when set)."
          },
          "priceTiers": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ItemPriceTier"
            },
            "description": "Quantity-break pricing, ascending by quantity. Empty when the item has a single price."
          },
          "barcodes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ItemBarcodeDto"
            },
            "description": "Barcodes attached to the item."
          },
          "stock": {
            "$ref": "#/components/schemas/StockSummary"
          },
          "parentItemId": {
            "type": "integer",
            "description": "Id of the parent template for variant children; null otherwise.",
            "format": "int32",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Full item as returned by `GET /api/v1/items/{id}`."
      },
      "ItemPriceTier": {
        "type": "object",
        "properties": {
          "minQuantity": {
            "type": "integer",
            "description": "Smallest quantity at which UnitPrice applies.",
            "format": "int32"
          },
          "unitPrice": {
            "type": "number",
            "description": "Price per unit at or above MinQuantity.",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "description": "Quantity-break price."
      },
      "ItemSummary": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id, usable in `/api/v1/items/{id}`.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device id; also the pagination cursor.",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Display name."
          },
          "description": {
            "type": "string",
            "description": "Free-text description, if any.",
            "nullable": true
          },
          "categoryId": {
            "type": "integer",
            "description": "Primary category id, if any.",
            "format": "int32",
            "nullable": true
          },
          "categoryName": {
            "type": "string",
            "description": "Primary category name, if any.",
            "nullable": true
          },
          "unitPrice": {
            "type": "number",
            "description": "Selling price from the template's `Unit Price` property; null when not priced.",
            "format": "double",
            "nullable": true
          },
          "cost": {
            "type": "number",
            "description": "Purchase cost from the template's `Cost` property; null when unknown.",
            "format": "double",
            "nullable": true
          },
          "onHand": {
            "type": "number",
            "description": "Units on hand across stock locations (job sites excluded). Decimal because items sold\nby sub-unit report partial rows; negative when the organization allows overselling.",
            "format": "double"
          },
          "isMiscItem": {
            "type": "boolean",
            "description": "True for ad-hoc \"misc\" items that never carry stock."
          },
          "isParentTemplate": {
            "type": "boolean",
            "description": "True for a grouping template whose children are the stocked variants."
          },
          "sellByUnits": {
            "type": "boolean",
            "description": "True when the item is sold in sub-units of a parent row."
          },
          "unitName": {
            "type": "string",
            "description": "Name of the sub-unit when SellByUnits is true.",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "description": "UTC creation time.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "UTC last modification time; null when never edited.",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One sellable/stockable item (an `ItemTemplate`) as listed by `GET /api/v1/items`.\nPhysical rows in inventory are instances of a template; the API exposes templates\nbecause that is what agents price, sell and reorder."
      },
      "JobAssigneeDto": {
        "type": "object",
        "properties": {
          "userId": {
            "type": "string",
            "description": "Identity user id of the assignee (matches `/api/v1/users`)."
          },
          "name": {
            "type": "string",
            "description": "Display name (first + last, or email when no name is set). Example: `Maria Lopez`."
          },
          "scheduleGroupId": {
            "type": "integer",
            "description": "Schedule group (crew) the assignment was made under, or null.",
            "format": "int32",
            "nullable": true
          },
          "scheduleGroupName": {
            "type": "string",
            "description": "Display name of `scheduleGroupId`. Example: `Crew A`.",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "description": "Note attached to the assignment, if any.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One staff member assigned to a job."
      },
      "JobDetailDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id used in `/api/v1/jobs/{id}`. Unique within this organization. Example: `1042`.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device identifier (GUID) — also the pagination cursor value. Example: `3f2504e0-4f89-11d3-9a0c-0305e82c3301`.",
            "format": "uuid"
          },
          "jobNumber": {
            "type": "string",
            "description": "Human-readable job number shown to staff and customers. Example: `J-2026-0042`."
          },
          "jobType": {
            "type": "string",
            "description": "Free-text job category configured by the organization. Example: `Installation`, `Repair`.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Workflow status name. One of `Draft`, `InspectionInProgress`, `Estimated`,\n`AwaitingOptionSelection`, `Scheduled`, `InProgress`, `Completed`,\n`Invoiced`, `Paid`, `Cancelled`, `Outstanding`."
          },
          "statusName": {
            "type": "string",
            "description": "Display label of the status as the organization has customised it (falls back to `status`). Example: `On Site`."
          },
          "paymentStatus": {
            "type": "string",
            "description": "Payment state, independent of workflow status: `Unpaid`, `Partial`, `Paid`, `Overpaid` or `Refunded`.",
            "nullable": true
          },
          "customerId": {
            "type": "integer",
            "description": "Id of the customer the job is for (see `/api/v1/customers/{id}`).",
            "format": "int32"
          },
          "customerName": {
            "type": "string",
            "description": "Customer display name at read time. Example: `Acme Property Management`."
          },
          "description": {
            "type": "string",
            "description": "Short description of the work. May be null for drafts.",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "description": "When the job was created, ISO-8601 UTC. Example: `2026-09-01T14:03:22Z`.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "Last modification time, ISO-8601 UTC (creation time when never modified). Use with the\n`updatedSince` filter to poll for changes.",
            "format": "date-time"
          },
          "scheduledDate": {
            "type": "string",
            "description": "Scheduled start as the organization's local wall-clock time with no UTC offset (the\n`date-time` format here is a local time, not RFC 3339 with zone), e.g.\n`2026-09-08T08:30:00`. Null when unscheduled.",
            "format": "date-time",
            "nullable": true
          },
          "scheduledDurationMinutes": {
            "type": "integer",
            "description": "Planned duration in minutes (default 120 when scheduled without an explicit duration). Null when unscheduled.",
            "format": "int32",
            "nullable": true
          },
          "startDate": {
            "type": "string",
            "description": "When work actually started, ISO-8601 UTC. Null until the job is in progress.",
            "format": "date-time",
            "nullable": true
          },
          "completedDate": {
            "type": "string",
            "description": "When work was completed, ISO-8601 UTC. Null until completed.",
            "format": "date-time",
            "nullable": true
          },
          "totalPrice": {
            "type": "number",
            "description": "Customer price before tax, after the job-level discount, in the organization's currency (decimal, e.g. `1250.00`).",
            "format": "double"
          },
          "notes": {
            "type": "string",
            "description": "Internal notes typed on the job itself (not the timeline). Null when empty.",
            "nullable": true
          },
          "siteAddress": {
            "$ref": "#/components/schemas/JobSiteAddressDto"
          },
          "materialPrice": {
            "type": "number",
            "description": "Sum of material lines before job-level discount and tax (decimal currency, e.g. `800.00`).",
            "format": "double"
          },
          "laborPrice": {
            "type": "number",
            "description": "Sum of labor lines before job-level discount and tax (decimal currency).",
            "format": "double"
          },
          "customChargesTotal": {
            "type": "number",
            "description": "Sum of custom charges before job-level discount and tax (decimal currency).",
            "format": "double"
          },
          "discountPercent": {
            "type": "number",
            "description": "Job-level discount as a percentage of every line (0–100), or null when a fixed amount / no discount applies.",
            "format": "double",
            "nullable": true
          },
          "discountAmount": {
            "type": "number",
            "description": "Job-level fixed discount amount (decimal currency), or null when a percentage / no discount applies.",
            "format": "double",
            "nullable": true
          },
          "taxRate": {
            "type": "number",
            "description": "Tax rate locked in at creation, as a percentage (e.g. `8.25`).",
            "format": "double"
          },
          "downPaymentAmount": {
            "type": "number",
            "description": "Money collected before invoicing (decimal currency), or null when none.",
            "format": "double",
            "nullable": true
          },
          "assignees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobAssigneeDto"
            },
            "description": "Staff assigned to the job (requires the Scheduling module to change). Empty when unassigned."
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobMaterialLineDto"
            },
            "description": "Material lines (parts/products) on the job, excluding removed lines."
          },
          "laborItems": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobLaborLineDto"
            },
            "description": "Labor lines on the job, excluding removed lines."
          },
          "updates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobNoteDto"
            },
            "description": "Timeline of notes, progress updates and status changes, oldest first."
          }
        },
        "additionalProperties": false,
        "description": "Full job as returned by `GET /api/v1/jobs/{id}`: everything in\n`JobSummaryDto` plus the job site, pricing breakdown, assignees, material\nand labor lines and the note/update timeline."
      },
      "JobLaborLineDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Line id.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device identifier of the line.",
            "format": "uuid"
          },
          "description": {
            "type": "string",
            "description": "What the work is. Example: `Replace torsion springs`."
          },
          "estimatedHours": {
            "type": "number",
            "description": "Hours quoted (decimal, e.g. `1.5`).",
            "format": "double"
          },
          "actualHours": {
            "type": "number",
            "description": "Hours actually worked (decimal). Zero until logged.",
            "format": "double"
          },
          "customerRate": {
            "type": "number",
            "description": "Hourly rate billed to the customer (decimal currency).",
            "format": "double"
          },
          "customerTotal": {
            "type": "number",
            "description": "Customer price for the line after line discount, before job discount and tax (decimal currency).",
            "format": "double"
          },
          "technicianId": {
            "type": "string",
            "description": "Identity user id of the technician the line is booked to, or null.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Line state name from the LaborItemStatus enum (e.g. `Planned`, `Scheduled`, `Completed`)."
          }
        },
        "additionalProperties": false,
        "description": "A labor line on a job."
      },
      "JobMaterialLineDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Line id.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device identifier of the line.",
            "format": "uuid"
          },
          "itemTemplateId": {
            "type": "integer",
            "description": "Item template (catalog product) id — see `/api/v1/items/{id}`.",
            "format": "int32"
          },
          "itemTemplateName": {
            "type": "string",
            "description": "Catalog product name at read time. Example: `Torsion Spring 2in x 32in`."
          },
          "quantity": {
            "type": "integer",
            "description": "Quantity ordered for the job (whole units).",
            "format": "int32"
          },
          "deliveredQuantity": {
            "type": "integer",
            "description": "Quantity already delivered to / used at the site.",
            "format": "int32"
          },
          "unitPrice": {
            "type": "number",
            "description": "Customer unit price before line discount (decimal currency).",
            "format": "double"
          },
          "discountPercent": {
            "type": "number",
            "description": "Line discount as a percentage (0–100).",
            "format": "double"
          },
          "discountAmount": {
            "type": "number",
            "description": "Line discount as a fixed amount (decimal currency).",
            "format": "double"
          },
          "totalPrice": {
            "type": "number",
            "description": "Extended customer price after line discount, before job discount and tax (decimal currency).",
            "format": "double"
          },
          "status": {
            "type": "string",
            "description": "Fulfilment state: `Planned`, `Reserved`, `Used` (delivered) or `Returned`."
          }
        },
        "additionalProperties": false,
        "description": "A material (part/product) line on a job."
      },
      "JobNoteDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Entry id.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device identifier of the entry.",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "description": "Kind of entry — JobUpdateType name such as `GeneralNote`, `ProgressUpdate`, `Issue`, `CustomerRequest` or `StatusChange`."
          },
          "description": {
            "type": "string",
            "description": "The note text."
          },
          "isInternal": {
            "type": "boolean",
            "description": "True when the note is staff-only and must not be shown to the customer."
          },
          "createdAt": {
            "type": "string",
            "description": "When the entry was written, ISO-8601 UTC.",
            "format": "date-time"
          },
          "createdByName": {
            "type": "string",
            "description": "Display name of the author, when recorded.",
            "nullable": true
          },
          "oldStatus": {
            "type": "string",
            "description": "For status-change entries: the previous status name; otherwise null.",
            "nullable": true
          },
          "newStatus": {
            "type": "string",
            "description": "For status-change entries: the new status name; otherwise null.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One entry on a job's timeline (note, progress update, issue, status change…)."
      },
      "JobSiteAddressDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Customer address id (see `/api/v1/customers/{id}/locations`).",
            "format": "int32"
          },
          "name": {
            "type": "string",
            "description": "Label the customer gave the address. Example: `Main Office`."
          },
          "address": {
            "type": "string",
            "description": "Street line 1.",
            "nullable": true
          },
          "address2": {
            "type": "string",
            "description": "Street line 2 (suite, unit).",
            "nullable": true
          },
          "city": {
            "type": "string",
            "description": "City.",
            "nullable": true
          },
          "state": {
            "type": "string",
            "description": "State / province.",
            "nullable": true
          },
          "postalCode": {
            "type": "string",
            "description": "Postal / ZIP code.",
            "nullable": true
          },
          "country": {
            "type": "string",
            "description": "Country, when recorded.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A customer address used as the job site."
      },
      "JobSummaryDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id used in `/api/v1/jobs/{id}`. Unique within this organization. Example: `1042`.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Stable cross-device identifier (GUID) — also the pagination cursor value. Example: `3f2504e0-4f89-11d3-9a0c-0305e82c3301`.",
            "format": "uuid"
          },
          "jobNumber": {
            "type": "string",
            "description": "Human-readable job number shown to staff and customers. Example: `J-2026-0042`."
          },
          "jobType": {
            "type": "string",
            "description": "Free-text job category configured by the organization. Example: `Installation`, `Repair`.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Workflow status name. One of `Draft`, `InspectionInProgress`, `Estimated`,\n`AwaitingOptionSelection`, `Scheduled`, `InProgress`, `Completed`,\n`Invoiced`, `Paid`, `Cancelled`, `Outstanding`."
          },
          "statusName": {
            "type": "string",
            "description": "Display label of the status as the organization has customised it (falls back to `status`). Example: `On Site`."
          },
          "paymentStatus": {
            "type": "string",
            "description": "Payment state, independent of workflow status: `Unpaid`, `Partial`, `Paid`, `Overpaid` or `Refunded`.",
            "nullable": true
          },
          "customerId": {
            "type": "integer",
            "description": "Id of the customer the job is for (see `/api/v1/customers/{id}`).",
            "format": "int32"
          },
          "customerName": {
            "type": "string",
            "description": "Customer display name at read time. Example: `Acme Property Management`."
          },
          "description": {
            "type": "string",
            "description": "Short description of the work. May be null for drafts.",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "description": "When the job was created, ISO-8601 UTC. Example: `2026-09-01T14:03:22Z`.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "Last modification time, ISO-8601 UTC (creation time when never modified). Use with the\n`updatedSince` filter to poll for changes.",
            "format": "date-time"
          },
          "scheduledDate": {
            "type": "string",
            "description": "Scheduled start as the organization's local wall-clock time with no UTC offset (the\n`date-time` format here is a local time, not RFC 3339 with zone), e.g.\n`2026-09-08T08:30:00`. Null when unscheduled.",
            "format": "date-time",
            "nullable": true
          },
          "scheduledDurationMinutes": {
            "type": "integer",
            "description": "Planned duration in minutes (default 120 when scheduled without an explicit duration). Null when unscheduled.",
            "format": "int32",
            "nullable": true
          },
          "startDate": {
            "type": "string",
            "description": "When work actually started, ISO-8601 UTC. Null until the job is in progress.",
            "format": "date-time",
            "nullable": true
          },
          "completedDate": {
            "type": "string",
            "description": "When work was completed, ISO-8601 UTC. Null until completed.",
            "format": "date-time",
            "nullable": true
          },
          "totalPrice": {
            "type": "number",
            "description": "Customer price before tax, after the job-level discount, in the organization's currency (decimal, e.g. `1250.00`).",
            "format": "double"
          }
        },
        "additionalProperties": false,
        "description": "One job as returned by `GET /api/v1/jobs`. A compact row for lists, boards and\nagents; call `GET /api/v1/jobs/{id}` for assignees, line items and notes."
      },
      "LocationStock": {
        "type": "object",
        "properties": {
          "locationId": {
            "type": "integer",
            "description": "Location id.",
            "format": "int32"
          },
          "locationName": {
            "type": "string",
            "description": "Location name."
          },
          "quantity": {
            "type": "integer",
            "description": "Whole item rows at the location (not deleted, not in a job site).",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "description": "Stock held at one location."
      },
      "ModuleSummary": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "description": "Stable module key (e.g. `scheduling`). Use this in code, never Name."
          },
          "name": {
            "type": "string",
            "description": "Display name."
          },
          "active": {
            "type": "boolean",
            "description": "True when the organization can use the module (included tier or activated)."
          }
        },
        "additionalProperties": false,
        "description": "One module from the catalog and whether the calling organization has it."
      },
      "ModulesResponse": {
        "type": "object",
        "properties": {
          "modules": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ModuleSummary"
            },
            "description": "Every catalog module with its activation state for this organization."
          },
          "permissions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "FlexPermissions names granted to the presented key."
          }
        },
        "additionalProperties": false,
        "description": "Capability discovery for an API key: the org's modules and the key's permissions."
      },
      "PagedResponseOfCustomerDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CustomerDto"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfCustomerLocationDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CustomerLocationDto"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfInvoiceSummary": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoiceSummary"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfItemSummary": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ItemSummary"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfJobSummaryDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobSummaryDto"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfScheduleGroupDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScheduleGroupDto"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PagedResponseOfUserSummaryDto": {
        "type": "object",
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/UserSummaryDto"
            },
            "description": "Items of this page in ascending creation order."
          },
          "nextCursor": {
            "type": "string",
            "description": "SyncId of the last item; pass it back as `?after=` to fetch the next page.\n`null` when this is the last page.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "One page of a cursor-paginated /api/v1 collection (API_AND_MCP.md:\n`?after=<syncId>&limit=`)."
      },
      "PaymentTerms": {
        "enum": [
          "DueOnReceipt",
          "Net15",
          "Net30",
          "Net45",
          "Net60",
          "COD",
          "Prepaid"
        ],
        "type": "string"
      },
      "PaymentsSummary": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "description": "Number of payments.",
            "format": "int32"
          },
          "totalPaid": {
            "type": "number",
            "description": "Sum of payment amounts.",
            "format": "double"
          },
          "lastPaymentDate": {
            "type": "string",
            "description": "Most recent payment date, ISO-8601 UTC; null when no payment has been recorded.",
            "format": "date-time",
            "nullable": true
          },
          "payments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InvoicePaymentDto"
            },
            "description": "Individual payments, oldest first."
          }
        },
        "additionalProperties": false,
        "description": "Payments applied to an invoice."
      },
      "PriceTier": {
        "enum": [
          "Standard",
          "Silver",
          "Gold",
          "Platinum",
          "Custom"
        ],
        "type": "string"
      },
      "ProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "URI identifying the problem type (RFC 7807). Currently the generic reference for the HTTP status.",
            "nullable": true
          },
          "title": {
            "type": "string",
            "description": "Short human-readable summary of the status, e.g. \"Not Found\".",
            "nullable": true
          },
          "status": {
            "type": "integer",
            "description": "HTTP status code, repeated from the response line.",
            "format": "int32",
            "nullable": true
          },
          "detail": {
            "type": "string",
            "description": "Explanation specific to this occurrence, e.g. \"Job 42 was not found in this organization.\"",
            "nullable": true
          },
          "instance": {
            "type": "string",
            "description": "Request path the problem occurred on, when known.",
            "nullable": true
          }
        },
        "additionalProperties": { },
        "description": "RFC 7807 problem detail (application/problem+json). Also carries a traceId string to quote in support requests."
      },
      "RescheduleJobRequest": {
        "required": [
          "start"
        ],
        "type": "object",
        "properties": {
          "start": {
            "type": "string",
            "description": "New start as the organization's local wall clock, `yyyy-MM-ddTHH:mm:ss` with no UTC offset (the `date-time` format here is a local time, not RFC 3339 with zone); a trailing `Z` is converted to local time. Example: `2026-09-08T08:30:00`.",
            "format": "date-time"
          },
          "end": {
            "type": "string",
            "description": "Optional new end, same local wall-clock format as `start`; when given it must be after `start` and sets the job's duration. Omit to keep the current duration.",
            "format": "date-time",
            "nullable": true
          },
          "note": {
            "maxLength": 1000,
            "minLength": 0,
            "type": "string",
            "description": "Optional reason recorded on the job.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/jobs/{id}/reschedule`."
      },
      "ScheduleEventAssigneeDto": {
        "type": "object",
        "properties": {
          "userId": {
            "type": "string",
            "description": "Identity user id (matches `/api/v1/users`)."
          },
          "name": {
            "type": "string",
            "description": "Display name. Example: `Maria Lopez`."
          },
          "groupId": {
            "type": "integer",
            "description": "Crew the assignment falls under (explicit, or the user's primary group), or null.",
            "format": "int32",
            "nullable": true
          },
          "groupName": {
            "type": "string",
            "description": "Crew display name, or null.",
            "nullable": true
          },
          "groupColor": {
            "type": "string",
            "description": "Crew hex colour, or null.",
            "nullable": true
          },
          "groupOrder": {
            "type": "integer",
            "description": "Crew sort order (lower first; `2147483647` when the user has no crew).",
            "format": "int32"
          }
        },
        "additionalProperties": false,
        "description": "An assignee on a schedule event."
      },
      "ScheduleEventDetailsDto": {
        "type": "object",
        "properties": {
          "jobNumber": {
            "type": "string",
            "description": "Human-readable job number. Example: `J-2026-0042`."
          },
          "customerName": {
            "type": "string",
            "description": "Customer display name."
          },
          "jobType": {
            "type": "string",
            "description": "Job category. Example: `Installation`.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "description": "Workflow status name (see `JobSummaryDto.Status` for the list)."
          },
          "totalPrice": {
            "type": "number",
            "description": "Customer price before tax (decimal currency).",
            "format": "double"
          },
          "description": {
            "type": "string",
            "description": "Job description, empty string when none."
          },
          "scheduledTime": {
            "type": "string",
            "description": "Start time formatted for display. Example: `8:30 AM`."
          },
          "durationMinutes": {
            "type": "integer",
            "description": "Planned duration in minutes. Example: `120`.",
            "format": "int32"
          },
          "durationText": {
            "type": "string",
            "description": "Duration formatted for display. Example: `2h`."
          },
          "assignees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScheduleEventAssigneeDto"
            },
            "description": "Assigned staff, ordered by crew then name."
          },
          "assigneeNames": {
            "type": "string",
            "description": "Comma-separated assignee names for one-line display. Empty when unassigned."
          },
          "groupIds": {
            "type": "array",
            "items": {
              "type": "integer",
              "format": "int32"
            },
            "description": "Distinct schedule group (crew) ids of the assignees."
          },
          "groupColor": {
            "type": "string",
            "description": "Hex colour of the first assignee's crew, used as the event accent; null when no crew colour applies.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Job facts attached to a `ScheduleEventDto`."
      },
      "ScheduleEventDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Job id — fetch details with `GET /api/v1/jobs/{id}`.",
            "format": "int32"
          },
          "title": {
            "type": "string",
            "description": "Event title: job number and customer, plus the description on a second line when present. Example: `J-2026-0042 - Acme Property`."
          },
          "start": {
            "type": "string",
            "description": "Start as the organization's local wall-clock time, `yyyy-MM-ddTHH:mm:ss` with no zone suffix. Example: `2026-09-08T08:30:00`."
          },
          "end": {
            "type": "string",
            "description": "End (start + duration), same format as `start`."
          },
          "url": {
            "type": "string",
            "description": "Relative API path of the job. Example: `/api/v1/jobs/1042`.",
            "nullable": true
          },
          "backgroundColor": {
            "type": "string",
            "description": "Hex colour for the event body, derived from the job status. Example: `#28a745`."
          },
          "borderColor": {
            "type": "string",
            "description": "Hex colour for the event border (currently equal to `backgroundColor`)."
          },
          "displayTime": {
            "type": "string",
            "description": "Start time formatted for display. Example: `8:30 AM`."
          },
          "extendedProps": {
            "$ref": "#/components/schemas/ScheduleEventDetailsDto"
          }
        },
        "additionalProperties": false,
        "description": "One scheduled job as a calendar event (`GET /api/v1/schedule`). Same shape as the\nfeed the web calendar consumes (`Jobs/GetCalendarEvents`) so a FullCalendar-style\nclient can render it directly."
      },
      "ScheduleGroupDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Numeric id of the group. Stable within this organization.",
            "format": "int32"
          },
          "syncId": {
            "type": "string",
            "description": "Globally unique sync id (GUID). Use it as the `after` pagination cursor.",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "Group name, e.g. `\"Install Crew A\"`."
          },
          "description": {
            "type": "string",
            "description": "Optional description shown on the dispatch board.",
            "nullable": true
          },
          "colorHex": {
            "type": "string",
            "description": "Hex colour used for calendar / board rendering, e.g. `\"#2e7d32\"`; null when unset.",
            "nullable": true
          },
          "parentGroupId": {
            "type": "integer",
            "description": "Id of the parent group (e.g. the division a crew belongs to); null for top-level groups.",
            "format": "int32",
            "nullable": true
          },
          "parentGroupName": {
            "type": "string",
            "description": "Name of the parent group; null for top-level groups.",
            "nullable": true
          },
          "displayOrder": {
            "type": "integer",
            "description": "Sort position among siblings (ascending). Pages are returned in id order, so sort\nby this client-side when rendering a board.",
            "format": "int32"
          },
          "members": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScheduleGroupMemberDto"
            },
            "description": "Members of this group, leads first. Only active users are listed."
          },
          "createdAt": {
            "type": "string",
            "description": "UTC creation timestamp.",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "description": "UTC timestamp of the last edit; null when never edited.",
            "format": "date-time",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "A scheduling group (division, crew or team) of the calling organization, as returned by\n`GET /api/v1/schedule-groups`. Groups form a tree via `parentGroupId`;\na user may belong to several groups. Requires the `scheduling` module."
      },
      "ScheduleGroupMemberDto": {
        "type": "object",
        "properties": {
          "userId": {
            "type": "string",
            "description": "User id (string GUID) — matches `id` from `GET /api/v1/users` and job assignee ids."
          },
          "displayName": {
            "type": "string",
            "description": "User's display name, e.g. `\"Sam Carter\"`."
          },
          "isLead": {
            "type": "boolean",
            "description": "True for the crew lead / foreman of this group."
          }
        },
        "additionalProperties": false,
        "description": "A user's membership in a schedule group."
      },
      "SetJobStatusRequest": {
        "type": "object",
        "properties": {
          "statusId": {
            "type": "integer",
            "description": "Target status definition id (one of the org's statuses).",
            "format": "int32",
            "nullable": true
          },
          "statusKey": {
            "maxLength": 100,
            "minLength": 0,
            "type": "string",
            "description": "Target status by name — a status definition name (case-insensitive) or a system status key such as `InProgress`, `Completed`.",
            "nullable": true
          },
          "note": {
            "maxLength": 1000,
            "minLength": 0,
            "type": "string",
            "description": "Optional note appended to the status-change history entry.",
            "nullable": true
          }
        },
        "additionalProperties": false,
        "description": "Body of `POST /api/v1/jobs/{id}/status`. Exactly one of `statusId` / `statusKey` is required."
      },
      "StockSummary": {
        "type": "object",
        "properties": {
          "onHand": {
            "type": "number",
            "description": "Total on hand; same semantics as `onHand`.",
            "format": "double"
          },
          "byLocation": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LocationStock"
            },
            "description": "Per-location breakdown of physical rows. Sums to the row count, which may differ from OnHand for oversold or sub-unit items."
          }
        },
        "additionalProperties": false,
        "description": "Stock position of an item."
      },
      "UserSummaryDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "User id (string GUID). Use it as an assignee id when scheduling jobs."
          },
          "displayName": {
            "type": "string",
            "description": "Display name, e.g. `\"Sam Carter\"`; falls back to the e-mail address when no name is recorded."
          },
          "email": {
            "type": "string",
            "description": "Login e-mail address; null when the account has none.",
            "nullable": true
          },
          "isActive": {
            "type": "boolean",
            "description": "False when the account is deactivated (such users are only returned when `includeInactive=true`)."
          },
          "scheduleGroupIds": {
            "type": "array",
            "items": {
              "type": "integer",
              "format": "int32"
            },
            "description": "Ids of the schedule groups the user belongs to (see `/api/v1/schedule-groups`). Empty when unassigned."
          }
        },
        "additionalProperties": false,
        "description": "A user of the calling organization as exposed to integrations by `GET /api/v1/users`:\ndisplay name and schedule-group membership only. No credentials, security stamps or\nrole details are ever returned. Requires the `scheduling` module."
      },
      "ValidationProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "description": "URI identifying the problem type (RFC 7807). Currently the generic reference for the HTTP status.",
            "nullable": true
          },
          "title": {
            "type": "string",
            "description": "Short human-readable summary of the status, e.g. \"Not Found\".",
            "nullable": true
          },
          "status": {
            "type": "integer",
            "description": "HTTP status code, repeated from the response line.",
            "format": "int32",
            "nullable": true
          },
          "detail": {
            "type": "string",
            "description": "Explanation specific to this occurrence, e.g. \"Job 42 was not found in this organization.\"",
            "nullable": true
          },
          "instance": {
            "type": "string",
            "description": "Request path the problem occurred on, when known.",
            "nullable": true
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Validation failures keyed by JSON field name in camelCase (an empty key holds body-level errors); each value is an array of messages, e.g. { \"name\": [\"The Name field is required.\"] }."
          }
        },
        "additionalProperties": { },
        "description": "RFC 7807 problem detail for 400 (malformed/invalid body) and 422 (semantically invalid) responses: the per-field messages are under errors. Also carries a traceId string to quote in support requests."
      }
    },
    "securitySchemes": {
      "ApiKey": {
        "type": "http",
        "description": "API key created in Flex. Send as `Authorization: Bearer flx_live_…`.",
        "scheme": "bearer",
        "bearerFormat": "flx_live_…"
      }
    }
  },
  "security": [
    {
      "ApiKey": [ ]
    }
  ],
  "tags": [
    {
      "name": "Customers",
      "description": "The calling organization's customers and their additional locations (job sites).\nMirrors the MVC Customers pages: reads need `ViewCustomers`, create/update need\n`ManageCustomers` on the API key. Soft-deleted customers are never returned or\neditable. Writes go through `ICustomerService` — the same code the UI uses."
    },
    {
      "name": "Invoices",
      "description": "The organization's invoices: headers, lines, payments and balance, plus creating a draft\nfrom a job and finalizing it. Writes go through `IInvoiceService` exactly like\nthe MVC Invoices pages."
    },
    {
      "name": "Items",
      "description": "Read access to the organization's items (ItemTemplates) with pricing and stock.\nStock semantics match the Items page: physical rows outside job sites, minus\noversold quantity when negative inventory is allowed, with sub-unit parents reporting\na decimal on-hand."
    },
    {
      "name": "Jobs",
      "description": "Jobs (API_AND_MCP.md). Reads (Phase 1a) need `ViewJobs`; writes (Phase 1b) need\n`ManageJobs`, an `Idempotency-Key` header, and go through the same\n`IJobWorkflowService` methods as the MVC Jobs pages — this controller only\nvalidates ids against the key's organization and maps results to problem+json. Every\nquery is scoped to the presented key's organization; ids from other organizations\nanswer 404. Writes are attributed to the key's creator (`ApiActor`)."
    },
    {
      "name": "Modules",
      "description": "Lets an agent or integration discover what it can do before calling anything else.\nReference implementation for the /api/v1 surface."
    },
    {
      "name": "Schedule",
      "description": "The organization's calendar of scheduled jobs (API_AND_MCP.md, Phase 1a). Requires the\nScheduling module; without it every call answers 403. Same data and shape as the web\ncalendar feed (`Jobs/GetCalendarEvents`), produced by the same service."
    },
    {
      "name": "ScheduleGroups",
      "description": "Read access to the calling organization's schedule groups (divisions / crews / teams)\nand their members. Requires the `scheduling` module and the `ViewJobs`\npermission — the same gate as the MVC assignment picker. Deleted groups are never returned."
    },
    {
      "name": "Users",
      "description": "Read access to the calling organization's users for scheduling purposes: display\nnames and schedule-group membership only. Requires the `scheduling` module and\nthe `ViewJobs` permission — the same gate as the MVC assignment picker."
    }
  ]
}