{
  "openapi": "3.1.0",
  "info": {
    "title": "Narrow.TV API",
    "version": "1.0.0",
    "summary": "Connect accounting, web shops, ERP and order handling to Narrow.TV.",
    "description": "The Narrow.TV REST API gives server-to-server access to POS data (transactions, Z reports, daily summaries), the product catalog, stock and kiosk / Scan & Order orders, per location. Webhooks push events.\n\n**Availability.** Free for accounts with at least one register (POS), order kiosk or Scan & Order screen. Otherwise every call returns 403 `api_not_available`.\n\n**Authentication.** Send `Authorization: Bearer ntk_…`. The account owner creates tokens in the dashboard (account menu → API-toegang) and gives each token scopes per location. A token is shown once; store it as a secret.\n\n**Scopes** (per location): `pos:read`, `catalog:read`, `catalog:write`, `stock:read`, `stock:write`, `orders:read`.\n\n**Conventions.** JSON in UTF-8. Money is integer cents with a `currency`. Times are ISO 8601 in UTC; date filters (YYYY-MM-DD) use the location's time zone (Europe/Amsterdam). Ids are uuids.\n\n**Pagination.** `?limit=` (default 50, max 200) and `?cursor=`; list responses are `{ data: [...], nextCursor }`.\n\n**Errors.** `{ error: { code, message } }` with 400, 401, 403, 404, 409, 422, 429 or 500. Codes: `unauthorized`, `forbidden_scope`, `forbidden_location`, `not_found`, `validation_failed`, `conflict`, `rate_limited`, `api_not_available`, `payload_too_large` (413, batch bodies over 2 MB), `server_error`.\n\n**Rate limits.** 120 requests per minute per token and 600 per account. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a 429 carries `Retry-After`.\n\n**Idempotency.** Writes accept an `Idempotency-Key` header (at most 100 characters). The same key with the same request within 24 hours returns the stored response, also when that was an error (4xx): use a new key after fixing a rejected request. 429 and 5xx responses are not stored. The same key with another request returns 409.\n\n**Bulk.** `POST /locations/{id}/products/batch` upserts up to 500 products and `PUT /locations/{id}/stock` sets up to 1000 stock levels, and `POST /locations/{id}/option-groups/batch` upserts up to 200 option groups with their options in one request, each counting as one request for the rate limit. Match products by your own article code (`externalId`), barcode or id.\n\n**Writes** are attributed to \"API: <token name>\" in the stock history.\n\n**Webhooks.** Configured by the owner in the dashboard, per event and location. Deliveries are POSTs signed with `NarrowTV-Signature: t=<unix>,v1=<hex hmac_sha256(secret, t + \".\" + body)>`. Answer 2xx within 10 s; failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. After 50 failures in a row the webhook is switched off and the owner is e-mailed. Events can arrive more than once and out of order: deduplicate on the event `id`.\n\nNo CORS: the API is meant for server-to-server use.",
    "contact": {
      "name": "Narrow.TV",
      "url": "https://narrow.tv"
    }
  },
  "servers": [
    {
      "url": "https://connect.narrow.tv/v1",
      "description": "Production"
    },
    {
      "url": "https://connect.sbx.narrow.tv/v1",
      "description": "Sandbox"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Locations",
      "description": "The locations a token can access, with its scopes."
    },
    {
      "name": "POS",
      "description": "Sales journal, Z reports and daily totals (pos:read)."
    },
    {
      "name": "Catalog",
      "description": "Categories, products and option groups (catalog:read, catalog:write)."
    },
    {
      "name": "Stock",
      "description": "Stock levels, counts, deliveries, corrections and movements (stock:read, stock:write)."
    },
    {
      "name": "Orders",
      "description": "Kiosk and Scan & Order orders (orders:read)."
    },
    {
      "name": "Webhooks",
      "description": "Events pushed to your endpoint."
    }
  ],
  "paths": {
    "/locations": {
      "get": {
        "operationId": "listLocations",
        "summary": "List locations",
        "description": "The locations this token has at least one scope for, with those scopes.",
        "tags": [
          "Locations"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Location"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/transactions": {
      "get": {
        "operationId": "listTransactions",
        "summary": "List transactions",
        "description": "Journal rows of the location, sorted by receipt number (ascending).\n\nRequired scope: `pos:read` for the location.",
        "tags": [
          "POS"
        ],
        "security": [
          {
            "bearerAuth": [
              "pos:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Transaction"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "pos:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "sale",
                "refund",
                "opening_float",
                "cash_in",
                "cash_out"
              ]
            }
          },
          {
            "name": "sinceReceipt",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Only receipts with a higher number (incremental sync)."
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/transactions?from=2026-09-01&to=2026-09-30' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/transactions/{id}": {
      "get": {
        "operationId": "getTransaction",
        "summary": "Get a transaction",
        "description": "One journal row of the location.\n\nRequired scope: `pos:read` for the location.",
        "tags": [
          "POS"
        ],
        "security": [
          {
            "bearerAuth": [
              "pos:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Transaction"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "pos:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Transaction id."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/transactions/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/z-reports": {
      "get": {
        "operationId": "listZReports",
        "summary": "List Z reports",
        "description": "Z reports (daily closings per register) of the location, sorted by closedAt (ascending).\n\nRequired scope: `pos:read` for the location.",
        "tags": [
          "POS"
        ],
        "security": [
          {
            "bearerAuth": [
              "pos:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ZReport"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "pos:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/z-reports?from=2026-09-01' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/daily-summary": {
      "get": {
        "operationId": "getDailySummary",
        "summary": "Daily summary",
        "description": "Sales and refunds of one day (Europe/Amsterdam), calculated from the journal, so also for days without a Z report.\n\nRequired scope: `pos:read` for the location.",
        "tags": [
          "POS"
        ],
        "security": [
          {
            "bearerAuth": [
              "pos:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/DailySummary"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "pos:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "YYYY-MM-DD; default today."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/daily-summary?date=2026-09-28' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/categories": {
      "get": {
        "operationId": "listCategories",
        "summary": "List categories",
        "description": "Categories of the location's range, sorted by updatedAt.\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Category"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/categories' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "post": {
        "operationId": "createCategory",
        "summary": "Create a category",
        "description": "\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Category"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CategoryCreate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/categories' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: 5f0c2c1e-8d0b-4c4e-9a53-1c7c0f6a2b11' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\": \"Drinks\"}'"
          }
        ]
      }
    },
    "/locations/{locationId}/categories/{id}": {
      "get": {
        "operationId": "getCategory",
        "summary": "Get a category",
        "description": "\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Category"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Category id."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/categories/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "patch": {
        "operationId": "updateCategory",
        "summary": "Update a category",
        "description": "Only the given fields change.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Category"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Category id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CategoryUpdate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PATCH 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/categories/{id}' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"isActive\": false}'"
          }
        ]
      },
      "delete": {
        "operationId": "deleteCategory",
        "summary": "Delete a category",
        "description": "Its products stay, without a category (their updatedAt changes).\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "204": {
            "$ref": "#/components/responses/NoContent"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Category id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X DELETE 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/categories/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/products": {
      "get": {
        "operationId": "listProducts",
        "summary": "List products",
        "description": "Products of the location's range, sorted by updatedAt (stock changes update it too).\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Product"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "name": "barcode",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 64
            },
            "description": "Exact barcode."
          },
          {
            "name": "categoryId",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "externalId",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/ExternalId"
            },
            "description": "Exact article code (externalId)."
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products?updatedSince=2026-09-28T00:00:00Z' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "post": {
        "operationId": "createProduct",
        "summary": "Create a product",
        "description": "Same validation as the dashboard. Stock is managed with the stock endpoints.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Product"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductCreate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: 5f0c2c1e-8d0b-4c4e-9a53-1c7c0f6a2b11' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\": \"Cappuccino\", \"priceCents\": 350, \"vatRate\": 9, \"barcode\": \"8712345678906\", \"externalId\": \"ART-10042\", \"imageUrl\": \"https://shop.example.com/images/cappuccino.jpg\"}'"
          }
        ]
      }
    },
    "/locations/{locationId}/products/batch": {
      "post": {
        "operationId": "batchUpsertProducts",
        "summary": "Create or update products in bulk",
        "description": "Upserts up to 500 products in one request (counts as one request for the rate limit). Each item is matched by `matchBy` (default `externalId`): a match is updated with only the given fields, no match is created (`createMissing`, default true) or fails with `not_found`. `categoryName` finds (or creates) a category by name. `imageUrl` queues a background download (see GET /image-jobs).\n\nItems are independent: an invalid item is reported in `results` and the others are saved (one transaction with a savepoint per item). The response is 200 even when items failed; check `failed` and each result. Webhooks get one `catalog.products.changed` event per batch instead of an event per product.\n\nBody at most 2 MB (413 `payload_too_large`). Send an `Idempotency-Key` to retry safely: a repeat returns the stored response.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BatchProductResponse"
                    }
                  }
                },
                "example": {
                  "data": {
                    "created": 2,
                    "updated": 0,
                    "unchanged": 0,
                    "failed": 1,
                    "imagesQueued": 1,
                    "results": [
                      {
                        "index": 0,
                        "status": "created",
                        "id": "0b6f7e1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
                        "externalId": "ART-10042",
                        "imageStatus": "pending"
                      },
                      {
                        "index": 1,
                        "status": "failed",
                        "error": {
                          "code": "validation_failed",
                          "message": "name is required to create a product."
                        }
                      },
                      {
                        "index": 2,
                        "status": "created",
                        "id": "5c2d9a7e-1b3f-4a6c-8d0e-2f4a6b8c0d1e",
                        "externalId": "ART-10044"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchProductRequest"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products/batch' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: sync-2026-09-29-chunk-001' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"matchBy\": \"externalId\", \"items\": [{\"externalId\": \"ART-10042\", \"name\": \"Cappuccino\", \"priceCents\": 350, \"vatRate\": 9, \"categoryName\": \"Coffee\"}, {\"externalId\": \"ART-10043\", \"priceCents\": 395}]}'"
          }
        ]
      }
    },
    "/locations/{locationId}/products/option-groups": {
      "post": {
        "operationId": "linkProductOptionGroups",
        "summary": "Link option groups to products",
        "description": "Adds and/or removes option groups on up to 1000 products of the location, by product id or externalId; groups by id or externalId. Added groups go after the product's current groups. A product can only use option groups of its own location: a group of another location is reported in `failed` with `forbidden_location`, unknown products and groups with `not_found`; the rest is saved. Counts as one request for the rate limit. Webhooks: one `catalog.products.changed` event (reason optionGroups) for the products that changed.\n\n`optionGroupIds` on a product (PATCH /products/{id} or the product batch) still sets a product's full list.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ProductOptionGroupLinksResponse"
                    }
                  }
                },
                "example": {
                  "data": {
                    "affected": 1,
                    "unchanged": 0,
                    "failed": [
                      {
                        "type": "product",
                        "ref": "ART-10043",
                        "error": {
                          "code": "not_found",
                          "message": "Product ART-10043 not found in this location."
                        }
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductOptionGroupLinksRequest"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products/option-groups' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: links-2026-09-29' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"productExternalIds\":[\"ART-10042\",\"ART-10043\"],\"add\":[\"SAUCES\"],\"remove\":[\"SIZES\"]}'"
          }
        ]
      }
    },
    "/locations/{locationId}/products/{id}": {
      "get": {
        "operationId": "getProduct",
        "summary": "Get a product",
        "description": "\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Product"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Product id."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "patch": {
        "operationId": "updateProduct",
        "summary": "Update a product",
        "description": "Only the given fields change; the image and availability dates set in the dashboard are kept.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Product"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Product id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProductUpdate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PATCH 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products/{id}' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"priceCents\": 375}'"
          }
        ]
      },
      "delete": {
        "operationId": "deleteProduct",
        "summary": "Delete a product",
        "description": "Stock movements and sales keep their history.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "204": {
            "$ref": "#/components/responses/NoContent"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Product id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X DELETE 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/products/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/option-groups": {
      "get": {
        "operationId": "listOptionGroups",
        "summary": "List option groups",
        "description": "Option groups (e.g. sauces, sizes) of the location with their options, oldest change first. `updatedAt` moves on every change of the group or its options.\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/OptionGroup"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "name": "externalId",
            "in": "query",
            "schema": {
              "$ref": "#/components/schemas/ExternalId"
            },
            "description": "Only the group with this externalId."
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "post": {
        "operationId": "createOptionGroup",
        "summary": "Create an option group",
        "description": "Creates a group with its options (at least one, at most 100). `maxSelect` 0 means no maximum; otherwise it must be at least `minSelect`. Link it to products with `optionGroupIds` on a product or POST /products/option-groups. Webhooks: `catalog.optiongroup.changed`.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OptionGroup"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "9a1f3c5e-7b2d-4e6f-8a0c-1d3e5f7a9b2c",
                    "externalId": "SAUCES",
                    "name": "Sauce",
                    "minSelect": 0,
                    "maxSelect": 2,
                    "position": 3,
                    "currency": "EUR",
                    "options": [
                      {
                        "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
                        "externalId": "S-MAYO",
                        "name": "Mayonnaise",
                        "priceCents": 50,
                        "isAvailable": true,
                        "position": 0
                      },
                      {
                        "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
                        "externalId": "S-KETCHUP",
                        "name": "Ketchup",
                        "priceCents": 50,
                        "isAvailable": true,
                        "position": 1
                      },
                      {
                        "id": "3e4f5a6b-7c8d-4e9f-8a1b-2c3d4e5f6a7b",
                        "externalId": "S-SATAY",
                        "name": "Satay sauce",
                        "priceCents": 75,
                        "isAvailable": false,
                        "position": 2
                      }
                    ],
                    "productCount": 12,
                    "updatedAt": "2026-09-29T08:15:30.123456Z"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OptionGroupCreate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: og-sauces-v1' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"externalId\":\"SAUCES\",\"name\":\"Sauce\",\"minSelect\":0,\"maxSelect\":2,\"options\":[{\"externalId\":\"S-MAYO\",\"name\":\"Mayonnaise\",\"priceCents\":50},{\"externalId\":\"S-KETCHUP\",\"name\":\"Ketchup\",\"priceCents\":50},{\"externalId\":\"S-SATAY\",\"name\":\"Satay sauce\",\"priceCents\":75,\"isAvailable\":false}]}'"
          }
        ]
      }
    },
    "/locations/{locationId}/option-groups/batch": {
      "post": {
        "operationId": "batchUpsertOptionGroups",
        "summary": "Create or update option groups in bulk",
        "description": "Upserts up to 200 option groups with their options in one request (counts as one request for the rate limit). Each item is matched by `matchBy` (default `externalId`): a match is updated with only the given fields (`options`, when sent, replaces the list and keeps the ids of matched options), no match is created (`createMissing`, default true; name and options required) or fails with `not_found`.\n\nItems are independent: an invalid item is reported in `results` and the others are saved (one transaction with a savepoint per item). The response is 200 even when items failed. Webhooks get one `catalog.optiongroup.changed` event `{ids, created, updated}` per batch.\n\nBody at most 2 MB (413 `payload_too_large`). An `Idempotency-Key` replays the whole stored response.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/BatchOptionGroupResponse"
                    }
                  }
                },
                "example": {
                  "data": {
                    "created": 1,
                    "updated": 1,
                    "unchanged": 0,
                    "failed": 0,
                    "results": [
                      {
                        "index": 0,
                        "status": "updated",
                        "id": "9a1f3c5e-7b2d-4e6f-8a0c-1d3e5f7a9b2c",
                        "externalId": "SAUCES"
                      },
                      {
                        "index": 1,
                        "status": "created",
                        "id": "4f5a6b7c-8d9e-4f0a-9b1c-2d3e4f5a6b7c",
                        "externalId": "SIZES"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchOptionGroupRequest"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/batch' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: options-sync-2026-09-29' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"matchBy\":\"externalId\",\"items\":[{\"externalId\":\"SAUCES\",\"name\":\"Sauce\",\"minSelect\":0,\"maxSelect\":2,\"options\":[{\"externalId\":\"S-MAYO\",\"name\":\"Mayonnaise\",\"priceCents\":50},{\"externalId\":\"S-KETCHUP\",\"name\":\"Ketchup\",\"priceCents\":50}]},{\"externalId\":\"SIZES\",\"name\":\"Size\",\"minSelect\":1,\"maxSelect\":1,\"options\":[{\"externalId\":\"SZ-S\",\"name\":\"Small\",\"priceCents\":0},{\"externalId\":\"SZ-L\",\"name\":\"Large\",\"priceCents\":100}]}]}'"
          }
        ]
      }
    },
    "/locations/{locationId}/option-groups/{id}": {
      "get": {
        "operationId": "getOptionGroup",
        "summary": "Get an option group",
        "description": "The group with its options.\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OptionGroup"
                    }
                  }
                },
                "example": {
                  "data": {
                    "id": "9a1f3c5e-7b2d-4e6f-8a0c-1d3e5f7a9b2c",
                    "externalId": "SAUCES",
                    "name": "Sauce",
                    "minSelect": 0,
                    "maxSelect": 2,
                    "position": 3,
                    "currency": "EUR",
                    "options": [
                      {
                        "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
                        "externalId": "S-MAYO",
                        "name": "Mayonnaise",
                        "priceCents": 50,
                        "isAvailable": true,
                        "position": 0
                      },
                      {
                        "id": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
                        "externalId": "S-KETCHUP",
                        "name": "Ketchup",
                        "priceCents": 50,
                        "isAvailable": true,
                        "position": 1
                      },
                      {
                        "id": "3e4f5a6b-7c8d-4e9f-8a1b-2c3d4e5f6a7b",
                        "externalId": "S-SATAY",
                        "name": "Satay sauce",
                        "priceCents": 75,
                        "isAvailable": false,
                        "position": 2
                      }
                    ],
                    "productCount": 12,
                    "updatedAt": "2026-09-29T08:15:30.123456Z"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "patch": {
        "operationId": "updateOptionGroup",
        "summary": "Update an option group",
        "description": "Only the given fields change. `options` replaces the whole list: listed options keep their id when they match an existing option (by id, externalId or name); options not listed are deleted. Open orders and receipts keep their own copy of the options. To change one option (e.g. sold out) use PATCH /option-groups/{id}/options/{optionId}.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/OptionGroup"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OptionGroupUpdate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PATCH 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"maxSelect\":3,\"options\":[{\"externalId\":\"S-MAYO\",\"priceCents\":60},{\"externalId\":\"S-KETCHUP\"},{\"externalId\":\"S-GARLIC\",\"name\":\"Garlic sauce\",\"priceCents\":60}]}'"
          }
        ]
      },
      "delete": {
        "operationId": "deleteOptionGroup",
        "summary": "Delete an option group",
        "description": "Deletes the group and its options and unlinks it from its products (their updatedAt changes; one `catalog.products.changed` event with reason optionGroups).\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "204": {
            "$ref": "#/components/responses/NoContent"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X DELETE 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/option-groups/{id}/options": {
      "post": {
        "operationId": "createOption",
        "summary": "Add an option",
        "description": "Adds one option to the group (at most 100 per group). The group's updatedAt changes.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Option"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OptionCreate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}/options' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"externalId\":\"S-GARLIC\",\"name\":\"Garlic sauce\",\"priceCents\":60}'"
          }
        ]
      }
    },
    "/locations/{locationId}/option-groups/{id}/options/{optionId}": {
      "patch": {
        "operationId": "updateOption",
        "summary": "Update an option",
        "description": "Only the given fields change. `isAvailable: false` marks the option sold out: kiosks and Scan & Order show it but it cannot be chosen.\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Option"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          },
          {
            "name": "optionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OptionUpdate"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PATCH 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}/options/{optionId}' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"isAvailable\":false}'"
          }
        ]
      },
      "delete": {
        "operationId": "deleteOption",
        "summary": "Delete an option",
        "description": "A group keeps at least one option (422 for the last one; delete the group instead).\n\nRequired scope: `catalog:write` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:write"
            ]
          }
        ],
        "responses": {
          "204": {
            "$ref": "#/components/responses/NoContent"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option group id."
          },
          {
            "name": "optionId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Option id."
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X DELETE 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/option-groups/{id}/options/{optionId}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/image-jobs": {
      "get": {
        "operationId": "listImageJobs",
        "summary": "Image download progress",
        "description": "Counts of the location's image downloads (`pending`, `done`, `failed`) and the jobs themselves, oldest change first. Filter with `?status=failed` to see which URLs could not be used and why. Images are processed in the background, usually within minutes; a job is tried up to 3 times.\n\nRequired scope: `catalog:read` for the location.",
        "tags": [
          "Catalog"
        ],
        "security": [
          {
            "bearerAuth": [
              "catalog:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ImageJobSummary"
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page of jobs; null on the last page."
                    }
                  }
                },
                "example": {
                  "data": {
                    "pending": 120,
                    "done": 19870,
                    "failed": 10,
                    "jobs": [
                      {
                        "productId": "0b6f7e1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
                        "externalId": "ART-10042",
                        "url": "https://shop.example.com/images/missing.jpg",
                        "status": "failed",
                        "attempts": 1,
                        "error": "The image server answered HTTP 404.",
                        "nextAttemptAt": null,
                        "updatedAt": "2026-09-29T08:15:30.123456Z"
                      }
                    ]
                  },
                  "nextCursor": null
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "catalog:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "done",
                "failed"
              ]
            },
            "description": "Only jobs with this status (the counts always cover all)."
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/image-jobs?status=failed' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/stock": {
      "get": {
        "operationId": "listStock",
        "summary": "List stock levels",
        "description": "Stock of every product of the location, sorted by updatedAt.\n\nRequired scope: `stock:read` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/StockItem"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/UpdatedSince"
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "put": {
        "operationId": "batchCountStock",
        "summary": "Set stock levels in bulk",
        "description": "Sets the stock of up to 1000 products in one request (count semantics: the quantity is what you counted; tracking is switched on). Name each product by `productId`, `externalId` or `barcode`. Items are independent: unknown products or invalid quantities are reported in `results`, the others are saved in one transaction. A tracked product whose quantity did not change writes no movement (`unchanged`). Webhooks get one `stock.batch_changed` event instead of `stock.changed` per product.\n\nBody at most 2 MB (413). Counts as one request for the rate limit.\n\nRequired scope: `stock:write` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/StockBatchResponse"
                    }
                  }
                },
                "example": {
                  "data": {
                    "updated": 2,
                    "unchanged": 0,
                    "failed": 1,
                    "results": [
                      {
                        "index": 0,
                        "status": "updated",
                        "productId": "0b6f7e1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
                        "before": 20,
                        "after": 24
                      },
                      {
                        "index": 1,
                        "status": "failed",
                        "error": {
                          "code": "not_found",
                          "message": "No product with barcode 8712345678913 at this location."
                        }
                      },
                      {
                        "index": 2,
                        "status": "updated",
                        "productId": "5c2d9a7e-1b3f-4a6c-8d0e-2f4a6b8c0d1e",
                        "before": 0,
                        "after": 12
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StockBatchRequest"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PUT 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: stock-2026-09-29-chunk-001' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"reason\": \"count\", \"items\": [{\"externalId\": \"ART-10042\", \"quantity\": 24}, {\"barcode\": \"8712345678913\", \"quantity\": 0}]}'"
          }
        ]
      }
    },
    "/locations/{locationId}/stock/{productId}": {
      "get": {
        "operationId": "getStock",
        "summary": "Get the stock of a product",
        "description": "\n\nRequired scope: `stock:read` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/StockItem"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "productId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock/{productId}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      },
      "put": {
        "operationId": "countStock",
        "summary": "Record a count",
        "description": "Sets the quantity, writes a `count` movement (staff name \"API: <token name>\") and turns on stock tracking.\n\nRequired scope: `stock:write` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/StockItem"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "productId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StockCount"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X PUT 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock/{productId}' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: 5f0c2c1e-8d0b-4c4e-9a53-1c7c0f6a2b11' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"quantity\": 24, \"note\": \"Weekly count\"}'"
          }
        ]
      }
    },
    "/locations/{locationId}/stock/adjustments": {
      "post": {
        "operationId": "adjustStock",
        "summary": "Deliveries and corrections",
        "description": "Adds or removes stock for several products in one transaction (all lines or none). Turns on stock tracking for these products. Lines name their product by `productId`, `externalId` or `barcode`.\n\nRequired scope: `stock:write` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:write"
            ]
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/StockAdjustmentResult"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:write",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StockAdjustment"
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl -X POST 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock/adjustments' \\\n  -H 'Authorization: Bearer ntk_...' \\\n  -H 'Idempotency-Key: 5f0c2c1e-8d0b-4c4e-9a53-1c7c0f6a2b11' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"reason\": \"delivery\", \"lines\": [{\"productId\": \"0b6f7e1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b\", \"delta\": 48}], \"note\": \"Supplier order 1042\"}'"
          }
        ]
      }
    },
    "/locations/{locationId}/stock-movements": {
      "get": {
        "operationId": "listStockMovements",
        "summary": "List stock movements",
        "description": "Every stock change of the location, sorted by time (ascending).\n\nRequired scope: `stock:read` for the location.",
        "tags": [
          "Stock"
        ],
        "security": [
          {
            "bearerAuth": [
              "stock:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/StockMovement"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "stock:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "name": "productId",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "reason",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "sale",
                "refund",
                "count",
                "correction",
                "delivery"
              ]
            }
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/stock-movements?from=2026-09-01&reason=sale' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/orders": {
      "get": {
        "operationId": "listOrders",
        "summary": "List orders",
        "description": "Kiosk and Scan & Order orders of the location, sorted by createdAt (ascending). Orders whose online payment never completed are left out.\n\nRequired scope: `orders:read` for the location.",
        "tags": [
          "Orders"
        ],
        "security": [
          {
            "bearerAuth": [
              "orders:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "nextCursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Order"
                      }
                    },
                    "nextCursor": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Pass as ?cursor= to get the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "orders:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "$ref": "#/components/parameters/From"
          },
          {
            "$ref": "#/components/parameters/To"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "paid",
                "preparing",
                "ready",
                "collected",
                "cancelled",
                "expired"
              ]
            }
          },
          {
            "name": "source",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "kiosk",
                "scan-order"
              ]
            }
          },
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/orders?from=2026-09-28&status=ready' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    },
    "/locations/{locationId}/orders/{id}": {
      "get": {
        "operationId": "getOrder",
        "summary": "Get an order",
        "description": "\n\nRequired scope: `orders:read` for the location.",
        "tags": [
          "Orders"
        ],
        "security": [
          {
            "bearerAuth": [
              "orders:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Order"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-required-scope": "orders:read",
        "parameters": [
          {
            "$ref": "#/components/parameters/LocationId"
          },
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Order id."
          }
        ],
        "x-codeSamples": [
          {
            "lang": "curl",
            "label": "curl",
            "source": "curl 'https://connect.narrow.tv/v1/locations/3f6d2a8e-5b1c-4d7e-9f20-8a1b2c3d4e5f/orders/{id}' \\\n  -H 'Authorization: Bearer ntk_...'"
          }
        ]
      }
    }
  },
  "webhooks": {
    "pos.transaction.created": {
      "post": {
        "summary": "pos.transaction.created",
        "description": "A journal row was added: sale, refund, opening float, cash in or out, or a kiosk / Scan & Order order paid online.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_pos_transaction_created",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_pos_transaction_created"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "pos.zreport.created": {
      "post": {
        "summary": "pos.zreport.created",
        "description": "A register closed its day (Z report).\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_pos_zreport_created",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_pos_zreport_created"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "stock.changed": {
      "post": {
        "summary": "stock.changed",
        "description": "Stock of a product changed (sale, refund, count, delivery, correction). Bursts are combined: at most one event per product per minute, with the latest quantity.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_stock_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_stock_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "order.created": {
      "post": {
        "summary": "order.created",
        "description": "A kiosk or Scan & Order order was accepted: paid online, or placed as pay-later.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_order_created",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_order_created"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "order.status_changed": {
      "post": {
        "summary": "order.status_changed",
        "description": "Staff changed the status of an order (preparing, ready, collected, cancelled).\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_order_status_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_order_status_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "catalog.product.changed": {
      "post": {
        "summary": "catalog.product.changed",
        "description": "A product was created, changed or deleted (dashboard, import or API). Deleted products send {id, deleted: true}.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_catalog_product_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_catalog_product_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "catalog.products.changed": {
      "post": {
        "summary": "catalog.products.changed",
        "description": "One event per product batch write (POST /products/batch) instead of catalog.product.changed per product, and one per location when background image downloads finished (reason: images), and one when products' option group links change (reason: optionGroups).\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_catalog_products_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_catalog_products_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "stock.batch_changed": {
      "post": {
        "summary": "stock.batch_changed",
        "description": "One event per stock batch (PUT /stock) instead of stock.changed per product.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_stock_batch_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_stock_batch_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "catalog.optiongroup.changed": {
      "post": {
        "summary": "catalog.optiongroup.changed",
        "description": "An option group was created, updated (including its options) or deleted, through the API or the dashboard. A batch (POST /option-groups/batch) sends one event with the ids.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_catalog_optiongroup_changed",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_catalog_optiongroup_changed"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    },
    "ping": {
      "post": {
        "summary": "ping",
        "description": "Sent by the Test button in the dashboard.\n\nDelivered as a POST with a JSON body to your https endpoint. Answer with a 2xx status within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h. Verify the `NarrowTV-Signature` header before trusting the body.",
        "operationId": "webhook_ping",
        "tags": [
          "Webhooks"
        ],
        "parameters": [
          {
            "name": "NarrowTV-Signature",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "example": "t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"
            },
            "description": "t = unix time of sending; v1 = hex HMAC-SHA256 with your webhook secret over `t + \".\" + raw body`. Reject timestamps older than 5 minutes."
          },
          {
            "name": "NarrowTV-Event",
            "in": "header",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/WebhookEventType"
            }
          },
          {
            "name": "NarrowTV-Event-Id",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Same as the body's id."
          },
          {
            "name": "NarrowTV-Delivery",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Id of this delivery (stays the same across retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Event_ping"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Delivered."
          },
          "default": {
            "description": "Any other status, a timeout or a redirect counts as a failed attempt."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ntk_<48 base62 characters>",
        "description": "API token from the dashboard (account menu → API-toegang). Scopes are granted per location."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "unauthorized",
                  "forbidden_scope",
                  "forbidden_location",
                  "not_found",
                  "validation_failed",
                  "conflict",
                  "rate_limited",
                  "api_not_available",
                  "server_error",
                  "payload_too_large"
                ]
              },
              "message": {
                "type": "string",
                "description": "A human-readable explanation (English)."
              }
            }
          }
        },
        "example": {
          "error": {
            "code": "forbidden_scope",
            "message": "This token lacks the stock:write scope for this location."
          }
        }
      },
      "Scope": {
        "type": "string",
        "enum": [
          "pos:read",
          "catalog:read",
          "catalog:write",
          "stock:read",
          "stock:write",
          "orders:read"
        ]
      },
      "Location": {
        "type": "object",
        "required": [
          "id",
          "name",
          "city",
          "country",
          "currency",
          "scopes"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "city": {
            "type": "string"
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2."
          },
          "currency": {
            "type": "string",
            "example": "EUR"
          },
          "scopes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Scope"
            },
            "description": "The scopes this token has for the location."
          }
        }
      },
      "VatRow": {
        "type": "object",
        "required": [
          "rate",
          "grossCents",
          "netCents",
          "vatCents"
        ],
        "properties": {
          "rate": {
            "type": "number",
            "description": "VAT rate in percent, e.g. 9 or 21."
          },
          "grossCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "netCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "vatCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          }
        }
      },
      "Payment": {
        "type": "object",
        "required": [
          "method",
          "amountCents",
          "tenderedCents",
          "changeCents"
        ],
        "properties": {
          "method": {
            "type": "string",
            "description": "cash, card or online (kiosk / Scan & Order paid online).",
            "example": "card"
          },
          "amountCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "tenderedCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "changeCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          }
        }
      },
      "LineOption": {
        "type": "object",
        "required": [
          "name",
          "priceCents"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "priceCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          }
        }
      },
      "TransactionLine": {
        "type": "object",
        "required": [
          "lineIndex",
          "productId",
          "barcode",
          "name",
          "quantity",
          "unitPriceCents",
          "grossCents",
          "discountCents",
          "discountLabel",
          "totalCents",
          "vatRate",
          "options",
          "refundOfLine",
          "restock"
        ],
        "properties": {
          "lineIndex": {
            "type": "integer"
          },
          "productId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "barcode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "quantity": {
            "type": "integer",
            "description": "Negative on refunds."
          },
          "unitPriceCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Unit price including options and VAT, before discounts."
          },
          "grossCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Line amount before discounts (incl. VAT)."
          },
          "discountCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Discount on this line (line and share of order discount)."
          },
          "discountLabel": {
            "type": "string"
          },
          "totalCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Line amount after discounts (incl. VAT)."
          },
          "vatRate": {
            "type": "number"
          },
          "options": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LineOption"
            }
          },
          "refundOfLine": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Refunds: the lineIndex of the refunded sale line."
          },
          "restock": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Refunds: whether the item went back into stock."
          }
        }
      },
      "Transaction": {
        "type": "object",
        "description": "A row of the sales journal (receipt).",
        "required": [
          "id",
          "receiptNumber",
          "type",
          "createdAt",
          "locationId",
          "registerId",
          "registerName",
          "staffName",
          "currency",
          "lines",
          "totalCents",
          "discountCents",
          "orderDiscount",
          "vatBreakdown",
          "payments",
          "refundOf",
          "kioskOrderId",
          "note"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "receiptNumber": {
            "type": "integer",
            "description": "Gapless per account (all locations)."
          },
          "type": {
            "type": "string",
            "enum": [
              "sale",
              "refund",
              "opening_float",
              "cash_in",
              "cash_out"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "registerId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "registerName": {
            "type": "string"
          },
          "staffName": {
            "type": "string"
          },
          "currency": {
            "type": "string",
            "example": "EUR"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TransactionLine"
            }
          },
          "totalCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Negative for refunds and cash_out."
          },
          "discountCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "orderDiscount": {
            "type": [
              "object",
              "null"
            ],
            "required": [
              "label",
              "cents"
            ],
            "properties": {
              "label": {
                "type": "string"
              },
              "cents": {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            }
          },
          "vatBreakdown": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VatRow"
            }
          },
          "payments": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Payment"
            }
          },
          "refundOf": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "kioskOrderId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "note": {
            "type": "string"
          }
        }
      },
      "ZReportTotals": {
        "type": "object",
        "description": "Same shape as the dashboard's Z report.",
        "properties": {
          "salesCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "refundsCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "netCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "transactionCount": {
            "type": "integer"
          },
          "refundCount": {
            "type": "integer"
          },
          "discountsCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "byVatRate": {
            "type": "object",
            "description": "Keyed by VAT rate (\"9\", \"21\").",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "grossCents": {
                  "type": "integer",
                  "description": "Amount in cents (integer)."
                },
                "vatCents": {
                  "type": "integer",
                  "description": "Amount in cents (integer)."
                }
              }
            }
          },
          "byPaymentMethod": {
            "type": "object",
            "description": "Keyed by method (cash, card, online): cents.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "cashInCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "cashOutCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "openingFloatCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          }
        }
      },
      "ZReport": {
        "type": "object",
        "required": [
          "id",
          "reportNumber",
          "locationId",
          "registerId",
          "registerName",
          "fromReceipt",
          "toReceipt",
          "openedAt",
          "closedAt",
          "currency",
          "totals",
          "expectedCashCents",
          "countedCashCents",
          "differenceCents",
          "staffName"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "reportNumber": {
            "type": "integer",
            "description": "Per register."
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "registerId": {
            "type": "string",
            "format": "uuid"
          },
          "registerName": {
            "type": "string"
          },
          "fromReceipt": {
            "type": [
              "integer",
              "null"
            ]
          },
          "toReceipt": {
            "type": [
              "integer",
              "null"
            ]
          },
          "openedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "closedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "currency": {
            "type": "string"
          },
          "totals": {
            "$ref": "#/components/schemas/ZReportTotals"
          },
          "expectedCashCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "countedCashCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "differenceCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "staffName": {
            "type": "string"
          }
        }
      },
      "DailySummary": {
        "type": "object",
        "required": [
          "date",
          "locationId",
          "currency",
          "salesCents",
          "refundsCents",
          "netCents",
          "discountsCents",
          "byVatRate",
          "byPaymentMethod",
          "transactionCount",
          "refundCount"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date"
          },
          "locationId": {
            "type": "string",
            "format": "uuid"
          },
          "currency": {
            "type": "string"
          },
          "salesCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "refundsCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Positive amount refunded."
          },
          "netCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "discountsCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "byVatRate": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VatRow"
            },
            "description": "Sales minus refunds per rate."
          },
          "byPaymentMethod": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "method",
                "cents"
              ],
              "properties": {
                "method": {
                  "type": "string"
                },
                "cents": {
                  "type": "integer",
                  "description": "Amount in cents (integer)."
                }
              }
            }
          },
          "transactionCount": {
            "type": "integer",
            "description": "Number of sales."
          },
          "refundCount": {
            "type": "integer"
          }
        }
      },
      "Category": {
        "type": "object",
        "required": [
          "id",
          "name",
          "position",
          "isActive",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "position": {
            "type": "integer"
          },
          "isActive": {
            "type": "boolean"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          }
        }
      },
      "CategoryCreate": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "isActive": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "CategoryUpdate": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "isActive": {
            "type": "boolean"
          }
        }
      },
      "Product": {
        "type": "object",
        "required": [
          "id",
          "categoryId",
          "name",
          "description",
          "priceCents",
          "currency",
          "vatRate",
          "barcode",
          "externalId",
          "isAvailable",
          "trackStock",
          "stockQuantity",
          "imageUrl",
          "imageStatus",
          "imageError",
          "position",
          "optionGroupIds",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "categoryId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string"
          },
          "priceCents": {
            "allOf": [
              {
                "type": "integer",
                "description": "Amount in cents (integer)."
              }
            ],
            "description": "Price including VAT."
          },
          "currency": {
            "type": "string",
            "example": "EUR"
          },
          "vatRate": {
            "type": "number",
            "example": 9
          },
          "barcode": {
            "type": "string",
            "description": "Empty when none. Unique per location."
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your article code, or null."
          },
          "isAvailable": {
            "type": "boolean"
          },
          "trackStock": {
            "type": "boolean",
            "description": "Read only: switched on by a stock count or adjustment."
          },
          "stockQuantity": {
            "type": "integer",
            "description": "Read only: use the stock endpoints."
          },
          "imageUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "description": "The stored photo (a copy on our servers, max 800 px), or null. Set it by sending imageUrl on a write."
          },
          "imageStatus": {
            "$ref": "#/components/schemas/ImageStatus"
          },
          "imageError": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the last imageUrl failed (only with imageStatus failed)."
          },
          "position": {
            "type": "integer"
          },
          "optionGroupIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          }
        }
      },
      "ProductCreate": {
        "type": "object",
        "required": [
          "name",
          "priceCents",
          "vatRate"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000
          },
          "vatRate": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "description": "Visible ASCII, no spaces. 409 when another product of the location has it."
          },
          "isAvailable": {
            "type": "boolean",
            "default": true
          },
          "categoryId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "optionGroupIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 50
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your article code; null removes it. 409 when another product of the location has it."
          },
          "imageUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "maxLength": 2000,
            "description": "https URL of a public JPEG, PNG, WebP or GIF image (at most 5 MB). It is downloaded in the background, scaled to at most 800 px and stored; imageStatus shows the progress. The same URL again changes nothing; null removes the photo. Our own imageUrl (as returned) links that photo directly."
          }
        },
        "description": "stockQuantity and trackStock cannot be written (422)."
      },
      "ProductUpdate": {
        "type": "object",
        "description": "Only the given fields change. stockQuantity and trackStock cannot be written (422).",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000
          },
          "vatRate": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "isAvailable": {
            "type": "boolean"
          },
          "categoryId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "optionGroupIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 50
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your article code; null removes it. 409 when another product of the location has it."
          },
          "imageUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "maxLength": 2000,
            "description": "https URL of a public JPEG, PNG, WebP or GIF image (at most 5 MB). It is downloaded in the background, scaled to at most 800 px and stored; imageStatus shows the progress. The same URL again changes nothing; null removes the photo. Our own imageUrl (as returned) links that photo directly."
          }
        }
      },
      "OptionGroup": {
        "type": "object",
        "required": [
          "id",
          "externalId",
          "name",
          "minSelect",
          "maxSelect",
          "position",
          "currency",
          "options",
          "productCount",
          "updatedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your own code for the group, unique per location, or null."
          },
          "name": {
            "type": "string"
          },
          "minSelect": {
            "type": "integer",
            "description": "Choices the customer must make (0: optional)."
          },
          "maxSelect": {
            "type": "integer",
            "description": "Choices the customer may make; 0: no maximum."
          },
          "position": {
            "type": "integer"
          },
          "currency": {
            "type": "string"
          },
          "options": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Option"
            },
            "description": "In display order."
          },
          "productCount": {
            "type": "integer",
            "description": "Products linked to this group. Link changes do not move the group's updatedAt."
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          }
        },
        "description": "An option group (e.g. sauces or sizes) of one location, with its options. Products link to groups of their own location (Product.optionGroupIds)."
      },
      "StockItem": {
        "type": "object",
        "required": [
          "productId",
          "name",
          "barcode",
          "trackStock",
          "quantity",
          "updatedAt"
        ],
        "properties": {
          "productId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "barcode": {
            "type": "string"
          },
          "trackStock": {
            "type": "boolean"
          },
          "quantity": {
            "type": "integer"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          }
        }
      },
      "StockCount": {
        "type": "object",
        "required": [
          "quantity"
        ],
        "properties": {
          "quantity": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1000000
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          }
        }
      },
      "StockAdjustment": {
        "type": "object",
        "required": [
          "lines",
          "reason"
        ],
        "properties": {
          "lines": {
            "type": "array",
            "minItems": 1,
            "maxItems": 500,
            "items": {
              "type": "object",
              "required": [
                "delta"
              ],
              "properties": {
                "productId": {
                  "type": "string",
                  "format": "uuid"
                },
                "externalId": {
                  "$ref": "#/components/schemas/ExternalId"
                },
                "barcode": {
                  "type": "string",
                  "maxLength": 64
                },
                "delta": {
                  "type": "integer",
                  "description": "Not 0; positive for a delivery. Stock never goes below 0.",
                  "minimum": -1000000,
                  "maximum": 1000000
                }
              },
              "description": "Name the product with exactly one of productId, externalId or barcode."
            }
          },
          "reason": {
            "type": "string",
            "enum": [
              "delivery",
              "correction"
            ]
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          }
        }
      },
      "StockAdjustmentResult": {
        "type": "object",
        "required": [
          "reason",
          "note",
          "lines"
        ],
        "properties": {
          "reason": {
            "type": "string",
            "enum": [
              "delivery",
              "correction"
            ]
          },
          "note": {
            "type": "string"
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "productId",
                "name",
                "before",
                "after",
                "delta"
              ],
              "properties": {
                "productId": {
                  "type": "string",
                  "format": "uuid"
                },
                "name": {
                  "type": "string"
                },
                "before": {
                  "type": "integer"
                },
                "after": {
                  "type": "integer"
                },
                "delta": {
                  "type": "integer",
                  "description": "The actual change."
                }
              }
            }
          }
        }
      },
      "StockMovement": {
        "type": "object",
        "required": [
          "id",
          "productId",
          "productName",
          "delta",
          "quantityAfter",
          "reason",
          "staffName",
          "note",
          "transactionId",
          "kioskOrderId",
          "countId",
          "createdAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "productId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "productName": {
            "type": "string"
          },
          "delta": {
            "type": "integer"
          },
          "quantityAfter": {
            "type": "integer"
          },
          "reason": {
            "type": "string",
            "enum": [
              "sale",
              "refund",
              "count",
              "correction",
              "delivery"
            ]
          },
          "staffName": {
            "type": "string",
            "description": "Register staff, or \"API: <token name>\" for API writes."
          },
          "note": {
            "type": "string"
          },
          "transactionId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "kioskOrderId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "countId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          }
        }
      },
      "OrderLine": {
        "type": "object",
        "required": [
          "productId",
          "name",
          "quantity",
          "unitPriceCents",
          "totalCents",
          "vatRate",
          "options"
        ],
        "properties": {
          "productId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "quantity": {
            "type": "integer"
          },
          "unitPriceCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "totalCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "vatRate": {
            "type": "number"
          },
          "options": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "group",
                "name",
                "priceCents"
              ],
              "properties": {
                "group": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "priceCents": {
                  "type": "integer",
                  "description": "Amount in cents (integer)."
                }
              }
            }
          }
        }
      },
      "Order": {
        "type": "object",
        "required": [
          "id",
          "orderNumber",
          "orderDate",
          "status",
          "source",
          "locationId",
          "currency",
          "lines",
          "totalCents",
          "vatBreakdown",
          "paymentMethod",
          "tableLabel",
          "language",
          "createdAt",
          "paidAt",
          "readyAt",
          "collectedAt",
          "settledAt",
          "settledTransactionId"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "orderNumber": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Per day, from 1."
          },
          "orderDate": {
            "type": "string",
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "paid",
              "preparing",
              "ready",
              "collected",
              "cancelled",
              "expired"
            ]
          },
          "source": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "kiosk",
              "scan-order",
              null
            ]
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "currency": {
            "type": "string"
          },
          "lines": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderLine"
            }
          },
          "totalCents": {
            "type": "integer",
            "description": "Amount in cents (integer)."
          },
          "vatBreakdown": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VatRow"
            }
          },
          "paymentMethod": {
            "type": "string",
            "enum": [
              "online",
              "later"
            ],
            "description": "later: paid at the counter or table (settled at a register)."
          },
          "tableLabel": {
            "type": [
              "string",
              "null"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "paidAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "readyAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "collectedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 in UTC, with microseconds (e.g. 2026-09-29T08:15:30.123456Z)."
          },
          "settledAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Pay-later orders: when settled at a POS register."
          },
          "settledTransactionId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          }
        }
      },
      "WebhookEventType": {
        "type": "string",
        "enum": [
          "pos.transaction.created",
          "pos.zreport.created",
          "stock.changed",
          "order.created",
          "order.status_changed",
          "catalog.product.changed",
          "catalog.products.changed",
          "stock.batch_changed",
          "catalog.optiongroup.changed",
          "ping"
        ]
      },
      "TruncatedData": {
        "type": "object",
        "required": [
          "id",
          "truncated"
        ],
        "description": "Data larger than 256 KB is replaced by its id: fetch it with the GET endpoint.",
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ]
          },
          "truncated": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "ProductChangedData": {
        "oneOf": [
          {
            "allOf": [
              {
                "$ref": "#/components/schemas/Product"
              },
              {
                "type": "object",
                "required": [
                  "deleted"
                ],
                "properties": {
                  "deleted": {
                    "type": "boolean",
                    "const": false
                  }
                }
              }
            ]
          },
          {
            "type": "object",
            "required": [
              "id",
              "deleted"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "deleted": {
                "type": "boolean",
                "const": true
              }
            }
          }
        ]
      },
      "OrderStatusChangedData": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Order"
          },
          {
            "type": "object",
            "required": [
              "previousStatus"
            ],
            "properties": {
              "previousStatus": {
                "type": "string"
              }
            }
          }
        ]
      },
      "Event_pos_transaction_created": {
        "type": "object",
        "description": "A journal row was added: sale, refund, opening float, cash in or out, or a kiosk / Scan & Order order paid online.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "pos.transaction.created"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Transaction"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_pos_zreport_created": {
        "type": "object",
        "description": "A register closed its day (Z report).",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "pos.zreport.created"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ZReport"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_stock_changed": {
        "type": "object",
        "description": "Stock of a product changed (sale, refund, count, delivery, correction). Bursts are combined: at most one event per product per minute, with the latest quantity.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "stock.changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/StockItem"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_order_created": {
        "type": "object",
        "description": "A kiosk or Scan & Order order was accepted: paid online, or placed as pay-later.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "order.created"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Order"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_order_status_changed": {
        "type": "object",
        "description": "Staff changed the status of an order (preparing, ready, collected, cancelled).",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "order.status_changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/OrderStatusChangedData"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_catalog_product_changed": {
        "type": "object",
        "description": "A product was created, changed or deleted (dashboard, import or API). Deleted products send {id, deleted: true}.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "catalog.product.changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ProductChangedData"
              },
              {
                "$ref": "#/components/schemas/TruncatedData"
              }
            ]
          }
        }
      },
      "Event_ping": {
        "type": "object",
        "description": "Sent by the Test button in the dashboard.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "ping"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "type": "object",
            "required": [
              "webhookId",
              "message"
            ],
            "properties": {
              "webhookId": {
                "type": "string",
                "format": "uuid"
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "ExternalId": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100,
        "pattern": "^[A-Za-z0-9._\\-/ ]+$",
        "description": "Your own article code (SKU) for the product: letters, digits, spaces and . _ - /. Unique per location. Use it to match products in batch writes and stock updates.",
        "example": "ART-10042"
      },
      "ImageStatus": {
        "type": "string",
        "enum": [
          "none",
          "pending",
          "done",
          "failed"
        ],
        "description": "`pending`: an imageUrl is queued and will be downloaded in the background (usually within minutes). `done`: the product has a photo (imageUrl points to the stored copy). `failed`: the last imageUrl could not be used (see imageError); a previous photo stays. `none`: no photo."
      },
      "BatchProductItem": {
        "type": "object",
        "description": "One product of a batch. It is matched by the request's matchBy field (externalId, barcode or id), so that field is required on every item (except with matchBy id: an item without id is created). A match is updated with only the given fields; no match is created (createMissing) with name, priceCents and vatRate required. stockQuantity and trackStock cannot be written (the item fails).",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The product id (matchBy id)."
          },
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 1000
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000
          },
          "vatRate": {
            "type": "number",
            "minimum": 0,
            "maximum": 100
          },
          "barcode": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64
          },
          "isAvailable": {
            "type": "boolean"
          },
          "categoryId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "optionGroupIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "maxItems": 50
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your article code; null removes it. 409 when another product of the location has it."
          },
          "imageUrl": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "maxLength": 2000,
            "description": "https URL of a public JPEG, PNG, WebP or GIF image (at most 5 MB). It is downloaded in the background, scaled to at most 800 px and stored; imageStatus shows the progress. The same URL again changes nothing; null removes the photo. Our own imageUrl (as returned) links that photo directly."
          },
          "categoryName": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 120,
            "description": "Alternative to categoryId: the category with this name in the location (case-insensitive). It is created when missing and createMissing is true. Empty or null removes the category. Do not send both."
          }
        }
      },
      "BatchProductRequest": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 500,
            "items": {
              "$ref": "#/components/schemas/BatchProductItem"
            }
          },
          "matchBy": {
            "type": "string",
            "enum": [
              "externalId",
              "barcode",
              "id"
            ],
            "default": "externalId",
            "description": "How items find their product in this location."
          },
          "createMissing": {
            "type": "boolean",
            "default": true,
            "description": "Create products (and categories named by categoryName) that do not exist. false: such items fail with not_found."
          }
        },
        "example": {
          "matchBy": "externalId",
          "createMissing": true,
          "items": [
            {
              "externalId": "ART-10042",
              "name": "Cappuccino",
              "priceCents": 350,
              "vatRate": 9,
              "barcode": "8712345678906",
              "categoryName": "Coffee",
              "imageUrl": "https://shop.example.com/images/cappuccino.jpg"
            },
            {
              "externalId": "ART-10043",
              "priceCents": 395
            },
            {
              "externalId": "ART-10044",
              "name": "Oat latte",
              "priceCents": 420,
              "vatRate": 9,
              "categoryName": "Coffee",
              "isAvailable": false
            }
          ]
        }
      },
      "BatchItemError": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "validation_failed",
              "conflict",
              "not_found",
              "server_error"
            ]
          },
          "message": {
            "type": "string"
          }
        }
      },
      "BatchProductResult": {
        "type": "object",
        "required": [
          "index",
          "status"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "Position of the item in the request (0-based)."
          },
          "status": {
            "type": "string",
            "enum": [
              "created",
              "updated",
              "unchanged",
              "failed"
            ],
            "description": "`unchanged`: the product already had these values (nothing written)."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The product (not on failed items)."
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "imageStatus": {
            "type": "string",
            "const": "pending",
            "description": "Present when this item queued an image download."
          },
          "error": {
            "$ref": "#/components/schemas/BatchItemError"
          }
        }
      },
      "BatchProductResponse": {
        "type": "object",
        "required": [
          "created",
          "updated",
          "unchanged",
          "failed",
          "imagesQueued",
          "results"
        ],
        "properties": {
          "created": {
            "type": "integer"
          },
          "updated": {
            "type": "integer"
          },
          "unchanged": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "imagesQueued": {
            "type": "integer",
            "description": "Image downloads queued by this batch (see GET /image-jobs)."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchProductResult"
            },
            "description": "One result per item, in request order."
          }
        }
      },
      "StockBatchItem": {
        "type": "object",
        "required": [
          "quantity"
        ],
        "description": "Name the product with exactly one of productId, externalId or barcode.",
        "properties": {
          "productId": {
            "type": "string",
            "format": "uuid"
          },
          "externalId": {
            "$ref": "#/components/schemas/ExternalId"
          },
          "barcode": {
            "type": "string",
            "maxLength": 64
          },
          "quantity": {
            "type": "integer",
            "minimum": 0,
            "maximum": 1000000,
            "description": "The counted quantity (the stock is set to it)."
          }
        }
      },
      "StockBatchRequest": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "items": {
              "$ref": "#/components/schemas/StockBatchItem"
            }
          },
          "reason": {
            "type": "string",
            "enum": [
              "count",
              "correction"
            ],
            "default": "count",
            "description": "Reason of the stock movements."
          },
          "note": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 500
          }
        },
        "example": {
          "reason": "count",
          "note": "Nightly ERP sync",
          "items": [
            {
              "externalId": "ART-10042",
              "quantity": 24
            },
            {
              "barcode": "8712345678913",
              "quantity": 0
            },
            {
              "productId": "0b6f7e1a-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
              "quantity": 12
            }
          ]
        }
      },
      "StockBatchResult": {
        "type": "object",
        "required": [
          "index",
          "status"
        ],
        "properties": {
          "index": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "updated",
              "unchanged",
              "failed"
            ],
            "description": "`unchanged`: the product already tracked stock with this quantity (no movement written)."
          },
          "productId": {
            "type": "string",
            "format": "uuid"
          },
          "before": {
            "type": "integer"
          },
          "after": {
            "type": "integer"
          },
          "error": {
            "$ref": "#/components/schemas/BatchItemError"
          }
        }
      },
      "StockBatchResponse": {
        "type": "object",
        "required": [
          "updated",
          "unchanged",
          "failed",
          "results"
        ],
        "properties": {
          "updated": {
            "type": "integer"
          },
          "unchanged": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StockBatchResult"
            }
          }
        }
      },
      "ImageJob": {
        "type": "object",
        "required": [
          "productId",
          "externalId",
          "url",
          "status",
          "attempts",
          "error",
          "nextAttemptAt",
          "updatedAt"
        ],
        "properties": {
          "productId": {
            "type": "string",
            "format": "uuid"
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The imageUrl that was sent."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "done",
              "failed"
            ]
          },
          "attempts": {
            "type": "integer",
            "description": "Download attempts so far (at most 3)."
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "nextAttemptAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Pending jobs: when the next attempt is due."
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ImageJobSummary": {
        "type": "object",
        "required": [
          "pending",
          "done",
          "failed",
          "jobs"
        ],
        "properties": {
          "pending": {
            "type": "integer"
          },
          "done": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ImageJob"
            },
            "description": "The jobs of this page (of one status with ?status=)."
          }
        }
      },
      "ProductsChangedData": {
        "type": "object",
        "required": [
          "ids",
          "created",
          "updated"
        ],
        "properties": {
          "ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The created and updated products (fetch them with GET /products?updatedSince= or per id)."
          },
          "created": {
            "type": "integer"
          },
          "updated": {
            "type": "integer"
          },
          "deletedIds": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Products deleted by a bulk action in the dashboard (reason bulk)."
          },
          "reason": {
            "type": "string",
            "enum": [
              "images",
              "bulk",
              "optionGroups"
            ],
            "description": "images: background image downloads finished (no other field changed). bulk: a bulk action in the dashboard changed or deleted these products. optionGroups: the products' option groups changed (POST /products/option-groups, or a linked group was deleted)."
          }
        }
      },
      "StockBatchChangedData": {
        "type": "object",
        "required": [
          "reason",
          "items"
        ],
        "properties": {
          "reason": {
            "type": "string",
            "enum": [
              "count",
              "correction"
            ]
          },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "productId",
                "quantity"
              ],
              "properties": {
                "productId": {
                  "type": "string",
                  "format": "uuid"
                },
                "quantity": {
                  "type": "integer"
                }
              }
            },
            "description": "The products whose stock changed, with the new quantity."
          }
        }
      },
      "Event_catalog_products_changed": {
        "type": "object",
        "description": "One event per product batch write (POST /products/batch) instead of catalog.product.changed per product, one per location when background image downloads finished (reason: images), and one per location for a bulk action in the dashboard (reason: bulk). Also one event when products' option group links change (reason: optionGroups).",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "catalog.products.changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "$ref": "#/components/schemas/ProductsChangedData"
          }
        }
      },
      "Event_stock_batch_changed": {
        "type": "object",
        "description": "One event per stock batch (PUT /stock) instead of stock.changed per product.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "stock.batch_changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "$ref": "#/components/schemas/StockBatchChangedData"
          }
        }
      },
      "Option": {
        "type": "object",
        "required": [
          "id",
          "externalId",
          "name",
          "priceCents",
          "isAvailable",
          "position"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your own code for the option, unique within its group, or null."
          },
          "name": {
            "type": "string"
          },
          "priceCents": {
            "type": "integer",
            "description": "Surcharge in cents (integer)."
          },
          "isAvailable": {
            "type": "boolean",
            "description": "false: sold out (shown but not orderable)."
          },
          "position": {
            "type": "integer"
          }
        }
      },
      "OptionInput": {
        "type": "object",
        "description": "An option in an `options` list. It keeps the id of the existing option it matches: by `id`, else by `externalId`, else (without both) by name, case-insensitive. Fields a matched option leaves out keep their value; a new option needs `name` (priceCents 0 and isAvailable true by default). The list order is the position.",
        "properties": {
          "id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "An option of this group (422 when it belongs to another group)."
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the option (unique within the group); null clears it, leaving it out keeps it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Surcharge in cents (integer, 0 to 10000000)."
          },
          "isAvailable": {
            "type": "boolean",
            "default": true
          }
        }
      },
      "OptionGroupCreate": {
        "type": "object",
        "required": [
          "name",
          "options"
        ],
        "properties": {
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the group (unique per location). null clears it; leaving it out keeps it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "minSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 0
          },
          "maxSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 1,
            "description": "0: no maximum. Otherwise at least minSelect."
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Display order; new groups go last by default."
          },
          "options": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/OptionInput"
            },
            "description": "At least one, at most 100 options."
          }
        },
        "example": {
          "externalId": "SAUCES",
          "name": "Sauce",
          "minSelect": 0,
          "maxSelect": 2,
          "options": [
            {
              "externalId": "S-MAYO",
              "name": "Mayonnaise",
              "priceCents": 50
            },
            {
              "externalId": "S-KETCHUP",
              "name": "Ketchup",
              "priceCents": 50
            },
            {
              "externalId": "S-SATAY",
              "name": "Satay sauce",
              "priceCents": 75,
              "isAvailable": false
            }
          ]
        }
      },
      "OptionGroupUpdate": {
        "type": "object",
        "description": "Only the given fields change.",
        "properties": {
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the group (unique per location). null clears it; leaving it out keeps it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "minSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 0
          },
          "maxSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 1,
            "description": "0: no maximum. Otherwise at least minSelect."
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Display order; new groups go last by default."
          },
          "options": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/OptionInput"
            },
            "description": "Replaces the whole option list (at least one, at most 100). Options not listed are deleted; matched options keep their id. Past orders and receipts keep their own copy of the options."
          }
        },
        "example": {
          "maxSelect": 3,
          "options": [
            {
              "externalId": "S-MAYO",
              "priceCents": 60
            },
            {
              "externalId": "S-KETCHUP"
            },
            {
              "externalId": "S-GARLIC",
              "name": "Garlic sauce",
              "priceCents": 60
            }
          ]
        }
      },
      "OptionCreate": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the option (unique within the group)."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Surcharge in cents (integer, 0 to 10000000)."
          },
          "isAvailable": {
            "type": "boolean",
            "default": true
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Default: after the last option."
          }
        },
        "example": {
          "externalId": "S-GARLIC",
          "name": "Garlic sauce",
          "priceCents": 60
        }
      },
      "OptionUpdate": {
        "type": "object",
        "description": "Only the given fields change.",
        "properties": {
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the option; null clears it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "priceCents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 10000000,
            "description": "Surcharge in cents (integer, 0 to 10000000)."
          },
          "isAvailable": {
            "type": "boolean",
            "default": true
          },
          "position": {
            "type": "integer",
            "minimum": 0
          }
        },
        "example": {
          "isAvailable": false
        }
      },
      "BatchOptionGroupItem": {
        "type": "object",
        "description": "The create / update fields of an option group plus `id` (matchBy id). With matchBy externalId the item needs `externalId`; with matchBy id an item without id is created.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "matchBy id only."
          },
          "externalId": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ExternalId"
              },
              {
                "type": "null"
              }
            ],
            "description": "Your code for the group (unique per location). null clears it; leaving it out keeps it."
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "minSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 0
          },
          "maxSelect": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "default": 1,
            "description": "0: no maximum. Otherwise at least minSelect."
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Display order; new groups go last by default."
          },
          "options": {
            "type": "array",
            "minItems": 1,
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/OptionInput"
            },
            "description": "Replaces the whole option list (at least one, at most 100). Options not listed are deleted; matched options keep their id. Past orders and receipts keep their own copy of the options."
          }
        }
      },
      "BatchOptionGroupRequest": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 200,
            "items": {
              "$ref": "#/components/schemas/BatchOptionGroupItem"
            }
          },
          "matchBy": {
            "type": "string",
            "enum": [
              "externalId",
              "id"
            ],
            "default": "externalId",
            "description": "How items find their option group in this location."
          },
          "createMissing": {
            "type": "boolean",
            "default": true,
            "description": "Create groups that do not exist (name and options required). false: such items fail with not_found."
          }
        },
        "example": {
          "matchBy": "externalId",
          "items": [
            {
              "externalId": "SAUCES",
              "name": "Sauce",
              "minSelect": 0,
              "maxSelect": 2,
              "options": [
                {
                  "externalId": "S-MAYO",
                  "name": "Mayonnaise",
                  "priceCents": 50
                },
                {
                  "externalId": "S-KETCHUP",
                  "name": "Ketchup",
                  "priceCents": 50
                }
              ]
            },
            {
              "externalId": "SIZES",
              "name": "Size",
              "minSelect": 1,
              "maxSelect": 1,
              "options": [
                {
                  "externalId": "SZ-S",
                  "name": "Small",
                  "priceCents": 0
                },
                {
                  "externalId": "SZ-L",
                  "name": "Large",
                  "priceCents": 100
                }
              ]
            }
          ]
        }
      },
      "BatchOptionGroupResult": {
        "type": "object",
        "required": [
          "index",
          "status"
        ],
        "properties": {
          "index": {
            "type": "integer",
            "description": "Position of the item in the request (0-based)."
          },
          "status": {
            "type": "string",
            "enum": [
              "created",
              "updated",
              "unchanged",
              "failed"
            ],
            "description": "`unchanged`: the group and its options already had these values (nothing written)."
          },
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The option group (not on failed items)."
          },
          "externalId": {
            "type": [
              "string",
              "null"
            ]
          },
          "error": {
            "$ref": "#/components/schemas/BatchItemError"
          }
        }
      },
      "BatchOptionGroupResponse": {
        "type": "object",
        "required": [
          "created",
          "updated",
          "unchanged",
          "failed",
          "results"
        ],
        "properties": {
          "created": {
            "type": "integer"
          },
          "updated": {
            "type": "integer"
          },
          "unchanged": {
            "type": "integer"
          },
          "failed": {
            "type": "integer"
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchOptionGroupResult"
            },
            "description": "One result per item, in request order."
          }
        }
      },
      "ProductOptionGroupLinksRequest": {
        "type": "object",
        "description": "Exactly one of productIds and productExternalIds; at least one group in add or remove.",
        "properties": {
          "productIds": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "productExternalIds": {
            "type": "array",
            "minItems": 1,
            "maxItems": 1000,
            "items": {
              "$ref": "#/components/schemas/ExternalId"
            }
          },
          "add": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "string"
            },
            "description": "Option group ids or externalIds to link (after the product's current groups)."
          },
          "remove": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "string"
            },
            "description": "Option group ids or externalIds to unlink."
          }
        },
        "example": {
          "productExternalIds": [
            "ART-10042",
            "ART-10043"
          ],
          "add": [
            "SAUCES"
          ],
          "remove": [
            "SIZES"
          ]
        }
      },
      "LinkFailure": {
        "type": "object",
        "required": [
          "type",
          "ref",
          "error"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "product",
              "optionGroup"
            ]
          },
          "ref": {
            "type": "string",
            "description": "The id or externalId as sent."
          },
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "not_found",
                  "forbidden_location"
                ],
                "description": "forbidden_location: the option group belongs to another location."
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "ProductOptionGroupLinksResponse": {
        "type": "object",
        "required": [
          "affected",
          "unchanged",
          "failed"
        ],
        "properties": {
          "affected": {
            "type": "integer",
            "description": "Products whose option groups changed."
          },
          "unchanged": {
            "type": "integer",
            "description": "Products that already had these links."
          },
          "failed": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LinkFailure"
            },
            "description": "Unknown products, and option groups that are unknown or of another location (skipped; the rest is saved)."
          }
        }
      },
      "OptionGroupChangedData": {
        "description": "One group write: the group as GET /option-groups/{id} returns it plus `deleted: false`, or `{id, deleted: true}` after a delete. A batch: `{ids, created, updated}`.",
        "oneOf": [
          {
            "allOf": [
              {
                "$ref": "#/components/schemas/OptionGroup"
              },
              {
                "type": "object",
                "required": [
                  "deleted"
                ],
                "properties": {
                  "deleted": {
                    "type": "boolean",
                    "const": false
                  }
                }
              }
            ]
          },
          {
            "type": "object",
            "required": [
              "id",
              "deleted"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "deleted": {
                "type": "boolean",
                "const": true
              }
            }
          },
          {
            "type": "object",
            "required": [
              "ids",
              "created",
              "updated"
            ],
            "properties": {
              "ids": {
                "type": "array",
                "items": {
                  "type": "string",
                  "format": "uuid"
                }
              },
              "created": {
                "type": "integer"
              },
              "updated": {
                "type": "integer"
              }
            }
          },
          {
            "$ref": "#/components/schemas/TruncatedData"
          }
        ]
      },
      "Event_catalog_optiongroup_changed": {
        "type": "object",
        "description": "An option group was created, updated (including its options) or deleted, through the API or the dashboard. A batch (POST /option-groups/batch) sends one event with the ids.",
        "required": [
          "id",
          "type",
          "createdAt",
          "locationId",
          "data"
        ],
        "properties": {
          "id": {
            "type": "string",
            "pattern": "^evt_[0-9a-f]{32}$",
            "description": "Unique event id: use it to ignore duplicates."
          },
          "type": {
            "type": "string",
            "const": "catalog.optiongroup.changed"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "locationId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "data": {
            "$ref": "#/components/schemas/OptionGroupChangedData"
          }
        }
      }
    },
    "parameters": {
      "LocationId": {
        "name": "locationId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "A location from GET /locations."
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200,
          "default": 50
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Opaque: nextCursor of the previous page (use the same filters)."
      },
      "From": {
        "name": "from",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Inclusive. YYYY-MM-DD (start of that day, Europe/Amsterdam) or an ISO 8601 date-time with time zone.",
        "example": "2026-09-01"
      },
      "To": {
        "name": "to",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "YYYY-MM-DD includes that whole day (Europe/Amsterdam); a date-time is exclusive.",
        "example": "2026-09-30"
      },
      "UpdatedSince": {
        "name": "updatedSince",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Only rows with updatedAt >= this instant (YYYY-MM-DD or ISO 8601 date-time with time zone). Results are sorted by updatedAt, so you can sync incrementally."
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "schema": {
          "type": "string",
          "maxLength": 100
        },
        "description": "Optional. Repeating a request with the same key and body within 24 hours returns the stored response (header Idempotent-Replayed: true), also when that response was an error (4xx): after fixing a rejected request, send it with a new key. The same key with another body returns 409. 429 and 5xx responses are not stored, so retry those with the same key."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid query parameter, cursor, header or JSON body (validation_failed).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing, unknown, revoked or expired token (unauthorized).",
        "headers": {
          "WWW-Authenticate": {
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "forbidden_location (no access to the location), forbidden_scope (scope missing) or api_not_available (the account has no register, order kiosk or Scan & Order screen).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "not_found.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "conflict: the barcode is already used at this location, or the Idempotency-Key was used for another request or is still being processed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "validation_failed: the body does not pass validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "rate_limited: 120 requests per minute per token, 600 per account.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait."
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ServerError": {
        "description": "server_error.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NoContent": {
        "description": "Deleted.",
        "headers": {
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          }
        }
      },
      "PayloadTooLarge": {
        "description": "payload_too_large: the body of a batch request is larger than 2 MB. Send fewer items per request.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "payload_too_large",
                "message": "The request body is too large (at most 2 MB). Send fewer items per batch."
              }
            }
          }
        }
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "schema": {
          "type": "integer"
        },
        "description": "Requests per minute for this token (120)."
      },
      "X-RateLimit-Remaining": {
        "schema": {
          "type": "integer"
        },
        "description": "Requests left in the current window."
      },
      "X-RateLimit-Reset": {
        "schema": {
          "type": "integer"
        },
        "description": "Unix time the window resets."
      }
    }
  }
}
