{
  "version": "v1",
  "base_url": "https://ctzrdkfnlwpnzmeycjpy.functions.supabase.co/public-api",
  "friendly_base_url": "https://api.pt24.at",
  "auth": {
    "methods": [
      {
        "type": "header",
        "name": "X-API-Key"
      },
      {
        "type": "query",
        "name": "key",
        "note": "Für Systeme die keine Custom Header senden können (z. B. 3CX External Phonebook)."
      }
    ]
  },
  "formats": [
    "json",
    "csv",
    "3cx",
    "vcard"
  ],
  "integrations": {
    "jtl_enabled": false,
    "jtl_note": "JTL ist deaktiviert: Aufträge, Kunden und Belege werden vollständig in PT24 geführt. JTL-Guards und -Jobs entfallen, POST /v1/orders/:id/jtl-sync antwortet 410 jtl_disabled.",
    "invoices_source": "legal_documents",
    "payments_source": "sevdesk",
    "payments_note": "Zahlungen werden in sevDesk erfasst. PT24 spiegelt den Zahlungsstand (stündlicher Poll, Push-Kanal POST /v1/legal-documents/:id/payment-status). POST/DELETE /v1/invoices/:id/payments antworten 410 payments_managed_in_sevdesk."
  },
  "scopes": [
    "customers:read",
    "contacts:read",
    "orders:read",
    "positions:read",
    "orders:write",
    "customers:write",
    "contacts:write",
    "invoices:read",
    "legal_documents:read",
    "legal_documents:sync",
    "legal_terms:read",
    "legal_terms:write",
    "files:read",
    "files:write",
    "suppliers:read",
    "suppliers:write",
    "messages:read",
    "messages:write",
    "spaces:read",
    "spaces:write",
    "qr_codes:read",
    "qr_codes:write",
    "*:read",
    "*:write"
  ],
  "enums": {
    "approval_status": [
      "pending",
      "approved",
      "rejected"
    ],
    "customer_type": [
      "company",
      "person"
    ],
    "invoice_status": [
      "draft",
      "sent",
      "paid",
      "partially_paid",
      "overdue",
      "cancelled"
    ],
    "order_status": [
      "draft",
      "pending_approval",
      "in_production",
      "completed",
      "cancelled",
      "pending_followup_question"
    ],
    "position_production_status": [
      "pending",
      "assigned",
      "in_progress",
      "completed",
      "pending_followup_question"
    ],
    "quote_status": [
      "draft",
      "sent",
      "accepted",
      "rejected",
      "expired"
    ]
  },
  "pagination": {
    "type": "cursor",
    "params": [
      "limit (default 100, max 1000)",
      "cursor (aus meta.next_cursor)"
    ],
    "example": {
      "first": "GET /v1/orders?limit=100",
      "next": "GET /v1/orders?limit=100&cursor=MjAyNi0wNS0...."
    }
  },
  "delta_sync": {
    "recommended_flow": [
      "1. Erster Aufruf ohne updated_since — alle Daten laden.",
      "2. server_time aus meta speichern.",
      "3. Nächster Aufruf mit ?updated_since={server_time} — nur Änderungen seither.",
      "4. Bei has_more=true mit cursor weiterblättern, bis next_cursor=null."
    ]
  },
  "idempotency": {
    "supported_endpoints": [
      "POST /v1/invoices/:id/payments",
      "POST /v1/legal-documents/:id/payment-status",
      "POST /v1/carts",
      "POST /v1/carts/:id/versions",
      "POST /v1/carts/:id/change-requests",
      "POST /v1/space-campaigns",
      "POST /v1/space-bookings",
      "POST /v1/qr-codes"
    ],
    "header": "Idempotency-Key",
    "ttl_hours": 24,
    "behavior": [
      "Client sendet eine eindeutige ID (UUID empfohlen) im Header 'Idempotency-Key'.",
      "Wird derselbe Key innerhalb von 24 h mit identischem Body erneut gesendet, liefert die API die ursprüngliche Response (Status inkl.) zurück — es entsteht kein doppelter Datensatz.",
      "Wird derselbe Key mit ABWEICHENDEM Body wiederverwendet, antwortet die API mit 409 idempotency_conflict.",
      "POST /v1/invoices/:id/payments besitzt zusätzlich eine natürliche Idempotenz über reference + amount je Rechnung: identische Kombination liefert die bestehende Zahlung zurück (meta.idempotent_replay=true, Status 200 statt 201). Bei mark_paid_full ohne reference setzt die API automatisch reference='mark_paid_full:<payment_date>' (meta.reference_auto_generated=true), sodass auch dort ein Retry nicht doppelt bucht.",
      "PATCH/DELETE/submit/release werden aktuell nicht per Idempotency-Key gesichert — sie sind aber ohnehin idempotent bzw. state-based (cart_locked-Guard)."
    ]
  },
  "dry_run": {
    "param": "?dry_run=true (alternativ { \"dry_run\": true } im Body)",
    "description": "Vorschau-Modus: Alle Guards und Validierungen laufen exakt wie beim echten Aufruf (is_cart, cart_locked, Pflichtfelder). Es wird KEIN Datensatz geschrieben.",
    "response": "200 { data: { dry_run: true, would_apply: {…}, warnings: [] }, meta: { dry_run: true } }",
    "supported_endpoints": [
      "POST /v1/invoices/:id/payments",
      "DELETE /v1/invoices/:id/payments/:paymentId",
      "POST /v1/legal-documents/:id/payment-status",
      "PATCH /v1/carts/:id",
      "PATCH /v1/orders/:id",
      "POST /v1/carts/:id/positions",
      "PATCH /v1/carts/:cart_id/positions/:position_id",
      "DELETE /v1/carts/:cart_id/positions/:position_id",
      "POST /v1/orders/:id/positions",
      "PATCH /v1/orders/:order_id/positions/:position_id",
      "DELETE /v1/orders/:order_id/positions/:position_id",
      "POST /v1/carts/:id/submit",
      "POST /v1/carts/:id/release",
      "POST /v1/orders/:id/jtl-sync",
      "POST /v1/carts/:id/accept",
      "POST /v1/carts/:id/versions",
      "POST /v1/orders/:id/responsibles",
      "DELETE /v1/orders/:id/responsibles/:responsible_id",
      "PUT /v1/orders/:order_id/positions/:position_id/assignee",
      "POST /v1/orders/:order_id/positions/:position_id/helpers",
      "DELETE /v1/orders/:order_id/positions/:position_id/helpers/:staff_user_id",
      "PUT /v1/legal-terms",
      "PUT /v1/legal-terms/:category",
      "PUT /v1/tax-notes",
      "PUT /v1/email-templates/:type_key",
      "PUT /v1/pdf-templates/:type_key",
      "POST /v1/space-campaigns",
      "POST /v1/space-bookings",
      "PATCH /v1/space-bookings/:id",
      "DELETE /v1/space-bookings/:id",
      "POST /v1/qr-codes",
      "PATCH /v1/qr-codes/:id",
      "DELETE /v1/qr-codes/:id",
      "POST /v1/qr-codes/:id/reset-stats",
      "POST /v1/qr-code-folders"
    ],
    "note": "Fehler (400/404/409) kommen im Vorschau-Modus genauso zurück wie im Echtbetrieb — dry_run meldet niemals einen Scheinerfolg."
  },
  "rate_limits": {
    "note": "Aktuell kein hartes Rate-Limit, aber Fair-Use: max. ~10 req/s pro Key empfohlen. Alle Aufrufe werden in system_logs protokolliert."
  },
  "errors": [
    {
      "code": "missing_api_key",
      "http": 401,
      "description": "X-API-Key Header oder ?key=… fehlt."
    },
    {
      "code": "invalid_api_key",
      "http": 401,
      "description": "API-Key ist unbekannt."
    },
    {
      "code": "key_inactive",
      "http": 403,
      "description": "Der Key wurde deaktiviert."
    },
    {
      "code": "key_expired",
      "http": 403,
      "description": "Der Key ist abgelaufen."
    },
    {
      "code": "insufficient_scope",
      "http": 403,
      "description": "Der Key besitzt nicht den geforderten Scope."
    },
    {
      "code": "invalid_param",
      "http": 400,
      "description": "Ein Query-Parameter ist ungültig."
    },
    {
      "code": "validation_error",
      "http": 400,
      "description": "Request-Body hat fehlende oder ungültige Felder."
    },
    {
      "code": "invalid_json",
      "http": 400,
      "description": "Request-Body ist kein gültiges JSON."
    },
    {
      "code": "conflict",
      "http": 409,
      "description": "Eindeutigkeits-Konflikt (z. B. vendor_code existiert bereits)."
    },
    {
      "code": "cart_locked",
      "http": 409,
      "description": "Warenkorb wurde bereits eingereicht/freigegeben — für Positionen des freigegebenen Auftrags /v1/orders/:id/positions… verwenden."
    },
    {
      "code": "position_locked",
      "http": 423,
      "description": "Position eines freigegebenen Auftrags ist gesperrt. details.blockers[] nennt jeden Grund einzeln: position_delivered (quantity_delivered > 0), position_produced (production_status=completed), order_cancelled. details.warnings[] sind nicht-blockierende Hinweise."
    },
    {
      "code": "optional_variant_conflict",
      "http": 400,
      "description": "is_optional und variant_group schließen sich aus: optional = Zusatzleistung (Kunde kann sie dazunehmen), variant_group = Auswahl zwischen Alternativen. Beim PATCH wird der gemergte Zielzustand geprüft — erst die andere Markierung entfernen (z. B. variant_group: null), dann setzen. Die Datenbank erzwingt die Regel zusätzlich (Constraint order_positions_optional_xor_variant)."
    },
    {
      "code": "discount_position_invalid",
      "http": 400,
      "description": "is_discount ist nur auf Hauptpositionen mit negativem Betrag erlaubt (z. B. unit_price: -250) und lässt sich nicht mit is_optional, variant_group oder sub_positions kombinieren. Beim PATCH wird der gemergte Zielzustand geprüft."
    },
    {
      "code": "tax_warning_missing_customer_vat_id",
      "http": 200,
      "description": "HINWEIS (kein Fehler, erscheint in warnings[]): Der Steuermodus reverse_charge (EU, Art. 196 MwSt-RL) oder reverse_charge_domestic (Inland-Bauleistung, § 19 Abs. 1a UStG) ist gesetzt, der Kunde hat aber keine UID-Nummer (customers.vat_id). Die UID ist Pflichtangabe auf dem Beleg — vor dem Ausstellen nachtragen."
    },
    {
      "code": "tax_warning_country_mismatch",
      "http": 200,
      "description": "HINWEIS (kein Fehler, erscheint in warnings[]): Steuermodus und Land der Rechnungsanschrift passen nicht zusammen. reverse_charge_domestic gilt nur bei Rechnungsland AT, reverse_charge nur bei einem EU-Land ausser AT."
    },
    {
      "code": "tax_warning_unknown_country",
      "http": 200,
      "description": "HINWEIS (kein Fehler, erscheint in warnings[]): In der Rechnungsanschrift ist kein Land (country_code) hinterlegt — der Reverse-Charge-Fall laesst sich nicht pruefen."
    },
    {
      "code": "variant_group_reordered",
      "http": 200,
      "description": "HINWEIS (kein Fehler, erscheint in warnings[]): Positionen derselben variant_group wurden automatisch zusammengeschoben, weil Variantengruppen im Angebot als ein Block untereinander stehen müssen. Gilt bei POST /v1/carts, POST /v1/{carts|orders}/:id/positions und beim Setzen von variant_group per PATCH. Details (Gruppe, alte/neue Reihenfolge) stehen im System-Log (category cart_positions)."
    },
    {
      "code": "variant_group_reorder_failed",
      "http": 200,
      "description": "HINWEIS in warnings[]: Die Positionen wurden gespeichert, das Zusammenschieben der Variantengruppe schlug aber fehl. Reihenfolge in der Oberfläche prüfen."
    },
    {
      "code": "order_locked",
      "http": 423,
      "description": "Auftrag ist gesperrt (z. B. storniert) — es können keine Positionen hinzugefügt werden. details.blockers[] nennt den Grund."
    },
    {
      "code": "quote_editing_not_open",
      "http": 409,
      "description": "Das Angebot wurde bereits versendet und ist gesperrt: ein neuer Versand friert Version n+1 ein und setzt eine eröffnete Bearbeitung voraus. details.open_editing_via nennt den Endpunkt (POST /v1/carts/:id/versions)."
    },
    {
      "code": "quote_editing_open",
      "http": 409,
      "description": "Resend über token_id ist nicht möglich, solange eine Bearbeitung offen ist — der Warenkorb weicht dann vom letzten eingefrorenen Stand ab. token_id weglassen: der Versand erzeugt Version n+1."
    },
    {
      "code": "not_submitted",
      "http": 409,
      "description": "Aktion setzt voraus, dass der Warenkorb bereits eingereicht wurde (cart_submitted_at IS NOT NULL)."
    },
    {
      "code": "not_accepted",
      "http": 409,
      "description": "Aktion setzt voraus, dass der Warenkorb vom Kunden akzeptiert wurde (cart_accepted_at IS NOT NULL)."
    },
    {
      "code": "already_released",
      "http": 409,
      "description": "Warenkorb ist bereits als Auftrag freigegeben (cart_released_at gesetzt)."
    },
    {
      "code": "customer_not_in_jtl",
      "http": 409,
      "description": "Nur bei integrations.jtl_enabled=true: Kunde hat keine jtl_customer_id — Freigabe/JTL-Sync wird abgelehnt, damit kein Auftrag ohne JTL-Auftragsnummer entsteht. Kunde zuerst nach JTL synchronisieren (Rechnungsadresse mit street/zip/city erforderlich), dann erneut freigeben bzw. POST /v1/orders/:id/jtl-sync aufrufen. Bei JTL aus tritt dieser Fehler nicht auf."
    },
    {
      "code": "jtl_disabled",
      "http": 410,
      "description": "JTL ist deaktiviert (integrations.jtl_enabled=false). JTL-spezifische Endpunkte wie POST /v1/orders/:id/jtl-sync sind nicht verfügbar; alle anderen Endpunkte arbeiten ohne JTL — meta.jtl_sync meldet dann { queued:false, reason:'jtl_disabled' }."
    },
    {
      "code": "position_not_in_cart",
      "http": 404,
      "description": "Position existiert nicht (mehr) in diesem Warenkorb."
    },
    {
      "code": "quote_version_locked",
      "http": 409,
      "description": "Der Warenkorb ist als Angebot versendet und damit eingefroren — Kopfdaten und Positionen sind gesperrt. details.latest_version nennt die geltende Version, details.open_editing_via den Weg heraus (POST /v1/carts/:id/versions = \"Neue Version beginnen\"). Solange eine Bearbeitung offen ist, sind Änderungen erlaubt."
    },
    {
      "code": "quote_version_superseded",
      "http": 409,
      "description": "Diese Fassung wurde durch eine neuere Version ersetzt — nur die neueste versendete Version ist annehmbar. details.latest_version nennt die geltende Fassung."
    },
    {
      "code": "quote_version_not_found",
      "http": 404,
      "description": "Zu diesem Angebot existiert keine Version mit dieser Nummer."
    },
    {
      "code": "quote_version_accepted",
      "http": 409,
      "description": "Die geltende Version wurde vom Kunden angenommen. Eine Bearbeitung zieht sie zurück (Status withdrawn) und braucht daher die ausdrückliche Bestätigung details.force_after_acceptance (Body-Feld force_after_acceptance: true)."
    },
    {
      "code": "no_quote_version",
      "http": 409,
      "description": "Zu diesem Angebot wurde noch nie eine Version eingefroren — es gibt nichts wiederherzustellen bzw. zu verwerfen."
    },
    {
      "code": "change_request_closed",
      "http": 409,
      "description": "Der Änderungswunsch ist bereits abgeschlossen (applied, rejected oder obsolete) und kann nicht erneut übernommen oder abgelehnt werden."
    },
    {
      "code": "reason_required",
      "http": 422,
      "description": "Die Ablehnung eines Änderungswunsches erfordert eine Begründung — sie geht als Mail an den Kunden."
    },
    {
      "code": "pdf_not_ready",
      "http": 409,
      "description": "Das eingefrorene PDF-Original ist noch nicht vorhanden (Speicherpfad oder SHA-256 fehlt). Bei Angebotsversionen zieht der Job quote_version_render_pdf es nach; die Version selbst ist trotzdem eingefroren."
    },
    {
      "code": "file_not_available",
      "http": 409,
      "description": "Für die Auftragsdatei ist weder eine Bucket-Ablage noch ein SharePoint-Link hinterlegt — sie ist über die API nicht abrufbar."
    },
    {
      "code": "render_failed",
      "http": 502,
      "description": "Die Auftragsbestätigung konnte nicht erzeugt werden (Renderdienst nicht erreichbar oder Fehler beim Zeichnen). Erneut versuchen; details nennt die Ursache."
    },
    {
      "code": "idempotency_conflict",
      "http": 409,
      "description": "Idempotency-Key wurde bereits mit anderem Request-Body verwendet. Neuen Key wählen oder Body identisch senden."
    },
    {
      "code": "payment_status_invalid",
      "http": 422,
      "description": "payment_status muss unpaid, partially_paid oder paid sein."
    },
    {
      "code": "paid_amount_negative",
      "http": 422,
      "description": "paid_amount darf nicht negativ sein. Eine Rückzahlung wird als eigener Beleg (Gutschrift/Storno) abgebildet, nicht als negativer Betrag."
    },
    {
      "code": "payment_status_inconsistent",
      "http": 422,
      "description": "payment_status passt nicht zu paid_amount. Regel: 0 = unpaid, zwischen 0 und Forderung = partially_paid, >= Forderung = paid (Überzahlung eingeschlossen). details.reference_total nennt die Forderung."
    },
    {
      "code": "legal_document_not_issued",
      "http": 409,
      "description": "Der Beleg ist ein Entwurf oder storniert — nur ausgestellte Belege tragen eine Forderung. Ein stornierter Beleg wird über seinen Gegenbeleg ausgeglichen."
    },
    {
      "code": "legal_document_not_internal",
      "http": 409,
      "description": "Der Beleg stammt aus dem JTL-Altbestand und ist in sevDesk noch nicht verifiziert (sevdesk_verify_status ≠ confirmed). Sobald der stündliche Abgleich den Voucher gefunden hat, kann der Zahlungsstand auch für Altbelege gespiegelt werden."
    },
    {
      "code": "payments_managed_in_sevdesk",
      "http": 410,
      "description": "Manuelles Buchen oder Stornieren von Zahlungen ist abgeschafft — Zahlungen werden in sevDesk geführt, PT24 spiegelt nur den Zahlungsstand (stündlicher Poll oder POST /v1/legal-documents/:id/payment-status)."
    },
    {
      "code": "booking_conflict",
      "http": 409,
      "description": "Die Werbefläche ist im gewählten Zeitraum bereits belegt — geprüft wird die Fläche selbst sowie über- und untergeordnete Flächen. details.conflicts[] nennt Buchung, Fläche, Zeitraum und Kampagne."
    },
    {
      "code": "space_customer_mismatch",
      "http": 409,
      "description": "Werbefläche und Kampagne gehören zu unterschiedlichen Kunden."
    },
    {
      "code": "booking_create_failed",
      "http": 500,
      "description": "Buchung konnte nicht angelegt werden."
    },
    {
      "code": "booking_update_failed",
      "http": 500,
      "description": "Buchung konnte nicht geändert werden."
    },
    {
      "code": "booking_delete_failed",
      "http": 500,
      "description": "Buchung konnte nicht storniert werden."
    },
    {
      "code": "campaign_create_failed",
      "http": 500,
      "description": "Kampagne konnte nicht angelegt werden."
    },
    {
      "code": "not_found",
      "http": 404,
      "description": "Ressource existiert nicht."
    },
    {
      "code": "unknown_endpoint",
      "http": 404,
      "description": "Endpoint existiert nicht. Siehe /v1/meta."
    },
    {
      "code": "format_unsupported",
      "http": 400,
      "description": "Format für diesen Endpoint nicht unterstützt."
    },
    {
      "code": "method_not_allowed",
      "http": 405,
      "description": "HTTP-Methode für diesen Endpoint nicht erlaubt."
    },
    {
      "code": "query_failed",
      "http": 500,
      "description": "DB-Query ist fehlgeschlagen."
    },
    {
      "code": "internal_error",
      "http": 500,
      "description": "Unerwarteter Server-Fehler."
    }
  ],
  "endpoints": [
    {
      "method": "GET",
      "path": "/v1/health",
      "description": "Healthcheck. Validiert API-Key und liefert Server-Zeit.",
      "params": [],
      "requiredScope": null,
      "example": "curl -H 'X-API-Key: pt24_…' https://api.pt24.at/v1/health"
    },
    {
      "method": "GET",
      "path": "/v1/meta",
      "description": "Diese Doku. Liefert alle Endpoints, Felder und Parameter als JSON. Kein Auth nötig.",
      "params": [],
      "requiredScope": null,
      "example": "curl https://api.pt24.at/v1/meta"
    },
    {
      "method": "GET",
      "path": "/v1/customers",
      "description": "Liste aller Kunden mit voll ausformatiertem JSON: Stammdaten, Adressen, Primary-Contact und optional alle Kontakte / Stats.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "type",
          "type": "string (enum)",
          "description": "customer_type Enum (z.B. business, private)."
        },
        {
          "name": "archived",
          "type": "true | false | only",
          "description": "Default false (nur aktive)."
        },
        {
          "name": "with_contacts",
          "type": "boolean",
          "description": "Default false. true = alle Kontakte einbetten (primary_contact ist immer dabei)."
        },
        {
          "name": "with_stats",
          "type": "boolean",
          "description": "Default false. true = orders_count + last_order_date je Kunde."
        }
      ],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/customers?updated_since=2026-01-01T00:00:00Z&limit=200'"
    },
    {
      "method": "GET",
      "path": "/v1/customers/:id",
      "description": "Einzelner Kunde im selben Format wie /v1/customers. with_contacts / with_stats werden unterstützt.",
      "params": [
        {
          "name": "with_contacts",
          "type": "boolean",
          "description": "Default true für Detail-Ansicht."
        },
        {
          "name": "with_stats",
          "type": "boolean",
          "description": "Default true für Detail-Ansicht."
        }
      ],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/customers/UUID"
    },
    {
      "method": "GET",
      "path": "/v1/customers/:id/contacts",
      "description": "Alle Kontakte eines Kunden (kein Customer-Embed, da Kontext klar). Wird gebraucht, um `contact_id` (Ansprechpartner) oder `responsibles[].contact_id` (Verantwortliche) für POST /v1/carts zu ermitteln. Liefert standardmäßig ALLE Kontakte — auch ohne Telefonnummer. Aktive Filter stehen in meta.filters_applied.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "with_phone",
          "type": "boolean",
          "description": "Default false. true = nur Kontakte mit Telefon/Mobil."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:read"
    },
    {
      "method": "GET",
      "path": "/v1/customers/:id/addresses",
      "description": "Alle Lieferadressen eines Kunden (customer_addresses). Wird gebraucht, um `delivery_address_id` für POST /v1/carts zu ermitteln. Sortiert nach is_default DESC.",
      "params": [],
      "fields": [
        "id",
        "type",
        "company",
        "salutation",
        "first_name",
        "last_name",
        "full_name",
        "street",
        "house_no",
        "address_addition",
        "zip",
        "city",
        "state",
        "country_code",
        "email",
        "phone",
        "is_default",
        "jtl_address_id"
      ],
      "requiredScope": "customers:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/customers/UUID/addresses"
    },
    {
      "method": "GET",
      "path": "/v1/customer-contacts",
      "description": "Liste aller Kundenkontakte mit eingebettetem Customer-Subset. Bestens geeignet für 3CX External Phonebook und CRM-Sync.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Kontakte dieses Kunden."
        },
        {
          "name": "with_phone",
          "type": "boolean",
          "description": "Default false bei format=json/csv, true bei format=3cx/vcard (Telefonbuch). true = nur Einträge mit Telefon/Mobil. Aktive Filter stehen in meta.filters_applied."
        },
        {
          "name": "is_primary",
          "type": "boolean",
          "description": "Nur Hauptkontakte."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:read",
      "example": "curl 'https://api.pt24.at/v1/customer-contacts?format=3cx&key=pt24_…'"
    },
    {
      "method": "GET",
      "path": "/v1/customer-contacts/:id",
      "description": "Einzelner Kontakt mit eingebettetem Customer-Subset.",
      "params": [],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:read"
    },
    {
      "method": "GET",
      "path": "/v1/orders",
      "description": "Liste aller Aufträge. Jeder Eintrag enthält das komplette Order-JSON inkl. Kunde, Adressen, Kontakt, Positionen (mit production-Block: status, assigned_to, completed_at, completed_by), responsibles[], fulfillments[] (Lieferungen mit Tracking + Items) und production_summary / fulfillment_summary. Bei Carts zusätzlich `quote` Block (submitted_at, accepted_at, active_share, share_history). Batch-Loading verhindert N+1.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Aufträge dieses Kunden."
        },
        {
          "name": "status",
          "type": "string (enum, Kommaliste)",
          "description": "order_status — erlaubte Werte: draft, pending_approval, in_production, completed, cancelled, pending_followup_question. Mehrere Werte per Komma. Unbekannte Werte liefern 400 invalid_param mit allowed_values.",
          "allowed_values": [
            "draft",
            "pending_approval",
            "in_production",
            "completed",
            "cancelled",
            "pending_followup_question"
          ]
        },
        {
          "name": "is_quote",
          "type": "boolean",
          "description": "true = nur Angebote/Carts, false = nur echte Aufträge. Default: false."
        },
        {
          "name": "archived",
          "type": "true | false | only",
          "description": "Default false (nur nicht-gelöschte)."
        },
        {
          "name": "include_cancelled",
          "type": "boolean",
          "description": "Default false."
        },
        {
          "name": "delivery_date_from",
          "type": "ISO date",
          "description": "delivery_date >= X."
        },
        {
          "name": "delivery_date_to",
          "type": "ISO date",
          "description": "delivery_date <= X."
        },
        {
          "name": "jtl_sync_status",
          "type": "string",
          "description": "Filter auf JTL-Sync-Status (z. B. \"synced\", \"pending\", \"error\", \"none\")."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/orders?status=in_production&updated_since=2026-05-01T00:00:00Z'"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id",
      "description": "Einzelner Auftrag im vollen Format wie /v1/orders (inkl. production, fulfillments, responsibles).",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/positions",
      "description": "Flache Positionsliste eines Auftrags inkl. production-Block (status, assigned_to, completed_at, completed_by) und BOM-Kinder auf gleicher Ebene (mit parent_position_id).",
      "params": [],
      "fields": [
        "id",
        "position_number",
        "description",
        "quantity",
        "unit_price_net",
        "tax_rate",
        "line_net",
        "line_gross",
        "production",
        "parent_position_id",
        "is_bom_child"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "GET",
      "path": "/v1/positions/search",
      "description": "Globale Positions-Recherche über historische Aufträge/Warenkörbe: Beschreibung, Notizen, SKU, Kunde, Zeitraum, Preis- und Mengenbereiche. Für Kalkulationsvergleiche durch ZOE und externe Agenten.",
      "params": [
        {
          "name": "q",
          "type": "string",
          "description": "Freitext über Beschreibung, Notizen und SKU."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Positionen dieses Kunden."
        },
        {
          "name": "sku",
          "type": "string",
          "description": "Exakte oder teilweise Artikelnummer."
        },
        {
          "name": "from",
          "type": "ISO timestamp/date",
          "description": "order.sales_order_date/created_at >= from."
        },
        {
          "name": "to",
          "type": "ISO timestamp/date",
          "description": "order.sales_order_date/created_at <= to."
        },
        {
          "name": "min_quantity",
          "type": "number",
          "description": "Mindestmenge."
        },
        {
          "name": "max_quantity",
          "type": "number",
          "description": "Maximalmenge."
        },
        {
          "name": "min_unit_price",
          "type": "number",
          "description": "Mindest-VK netto pro Einheit."
        },
        {
          "name": "max_unit_price",
          "type": "number",
          "description": "Maximal-VK netto pro Einheit."
        },
        {
          "name": "include_carts",
          "type": "boolean",
          "description": "Default true. false = nur freigegebene Aufträge."
        },
        {
          "name": "include_cancelled",
          "type": "boolean",
          "description": "Default false."
        },
        {
          "name": "limit",
          "type": "int",
          "description": "1–200, default 50."
        },
        {
          "name": "cursor",
          "type": "base64",
          "description": "Von meta.next_cursor der vorherigen Antwort."
        }
      ],
      "fields": [
        "id",
        "order_id",
        "order_number",
        "position_number",
        "sku",
        "title",
        "description",
        "notes",
        "internal_notes",
        "quantity",
        "unit",
        "unit_price_net",
        "purchase_price_net",
        "discount_pct",
        "tax_rate",
        "line_net",
        "line_gross",
        "sales_price_gross",
        "customer",
        "order",
        "production",
        "bom",
        "customer_product_id",
        "supplier_product_id",
        "configuration",
        "catalog_config",
        "source",
        "jtl_line_item_id",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "positions:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/positions/search?q=Rollup&customer_id=UUID&limit=50'"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/fulfillments",
      "description": "Alle Lieferungen/Fulfillments eines Auftrags. Enthält type (pickup, shipment, delivery, installation, completion_only), status (draft, ready, in_transit, shipped, delivered, installed), Carrier + Tracking-No, scheduled_date/-time, delivered_at, dismantling-Kette und items[] (welche Positionen wie viel).",
      "params": [],
      "fields": [
        "id",
        "fulfillment_number",
        "type",
        "status",
        "carrier",
        "tracking_no",
        "scheduled_date",
        "shipping_date",
        "delivered_at",
        "items"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "GET",
      "path": "/v1/invoices",
      "description": "Alle Rechnungen in EINEM Format — in PT24 ausgestellte Belege (source=internal) und der übernommene JTL-Altbestand (source=jtl) aus legal_documents. Je Eintrag: Customer, Billing-Address (Snapshot des Belegs), Positionen (aus dem eingefrorenen Beleg), Totals (reference_total = Restbetrag nach Anzahlungen, paid_amount, outstanding_amount) und payment_status (unpaid|partially_paid|paid, Quelle sevDesk). status bleibt im bisherigen Vokabular (sent|paid|partially_paid|overdue|cancelled|draft). `id` ist die Beleg-ID; alte Rechnungs-IDs stehen in legacy_invoice_id und werden bei GET /v1/invoices/:id weiterhin aufgelöst. Standard: Rechnungen, Rechnungskorrekturen und Anzahlungsrechnungen ohne Entwürfe (kind=…, include_drafts=true erweitern). payments[] ist leer — Einzelzahlungen liegen in sevDesk (meta.payments_source).",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Rechnungen dieses Kunden."
        },
        {
          "name": "order_id",
          "type": "uuid",
          "description": "Nur Rechnungen dieses Auftrags."
        },
        {
          "name": "status",
          "type": "string (enum, Kommaliste)",
          "description": "Abgeleitet aus Belegstatus, Zahlungsstand und Fälligkeit: draft, sent (offen, nicht fällig), paid, partially_paid, overdue (offen und fällig), cancelled. Alias: open|offen|unpaid = sent,partially_paid,overdue.",
          "allowed_values": [
            "draft",
            "sent",
            "paid",
            "partially_paid",
            "overdue",
            "cancelled"
          ],
          "aliases": {
            "open": [
              "sent",
              "partially_paid",
              "overdue"
            ]
          }
        },
        {
          "name": "kind",
          "type": "string (enum, Kommaliste)",
          "description": "Belegart: invoice, invoice_correction, down_payment (Standard) — zusätzlich credit_note, invoice_cancellation.",
          "allowed_values": [
            "invoice",
            "down_payment",
            "credit_note",
            "invoice_correction",
            "invoice_cancellation"
          ]
        },
        {
          "name": "source",
          "type": "internal | jtl",
          "description": "Herkunft: internal = in PT24 ausgestellt, jtl = übernommener Altbestand."
        },
        {
          "name": "include_drafts",
          "type": "boolean",
          "description": "Default false. Entwürfe (status=draft) einschließen."
        },
        {
          "name": "issue_since",
          "type": "ISO date",
          "description": "Belegdatum >= X."
        },
        {
          "name": "invoice_date_since",
          "type": "ISO date",
          "description": "Belegdatum >= X (Alias zu issue_since)."
        },
        {
          "name": "invoice_date_before",
          "type": "ISO date",
          "description": "Belegdatum <= X."
        },
        {
          "name": "due_before",
          "type": "ISO date",
          "description": "due_date <= X."
        }
      ],
      "fields": [
        "id",
        "legacy_invoice_id",
        "invoice_number",
        "jtl_invoice_id",
        "kind",
        "source",
        "status",
        "document_status",
        "payment_status",
        "status_text",
        "is_cancelled",
        "cancels_document_id",
        "correction_group_id",
        "order_id",
        "order_number",
        "quote_id",
        "customer_id",
        "invoice_date",
        "issue_date",
        "issue_date_legacy",
        "due_date",
        "paid_date",
        "paid_at",
        "payment_method",
        "payment_reference",
        "notes",
        "customer",
        "billing_address",
        "contact",
        "totals",
        "positions_count",
        "positions",
        "payments",
        "payments_source",
        "sevdesk",
        "pdf_available",
        "pdf_sha256",
        "finalized_at",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "invoices:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/invoices?status=open&customer_id=UUID'"
    },
    {
      "method": "GET",
      "path": "/v1/invoices/:id",
      "description": "Einzelne Rechnung im selben Format wie /v1/invoices. :id ist die Beleg-ID (legal_documents) ODER eine alte Rechnungs-ID (invoices) — letztere wird über legacy_invoice_id aufgelöst (meta.resolved_by).",
      "params": [],
      "fields": [
        "id",
        "legacy_invoice_id",
        "invoice_number",
        "jtl_invoice_id",
        "kind",
        "source",
        "status",
        "document_status",
        "payment_status",
        "status_text",
        "is_cancelled",
        "cancels_document_id",
        "correction_group_id",
        "order_id",
        "order_number",
        "quote_id",
        "customer_id",
        "invoice_date",
        "issue_date",
        "issue_date_legacy",
        "due_date",
        "paid_date",
        "paid_at",
        "payment_method",
        "payment_reference",
        "notes",
        "customer",
        "billing_address",
        "contact",
        "totals",
        "positions_count",
        "positions",
        "payments",
        "payments_source",
        "sevdesk",
        "pdf_available",
        "pdf_sha256",
        "finalized_at",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "invoices:read"
    },
    {
      "method": "GET",
      "path": "/v1/invoices/:id/payments",
      "description": "VERALTET — Einzelzahlungen werden in sevDesk geführt. Liefert data=[] und in meta.invoice den gespiegelten Zahlungsstand des Belegs (payment_status, paid_amount, outstanding_amount, sevdesk_voucher_id). Für noch nicht übernommene Altrechnungen werden die historischen lokalen Zahlungen weiterhin ausgegeben.",
      "params": [],
      "fields": [
        "id",
        "invoice_id",
        "amount",
        "payment_date",
        "payment_method",
        "reference",
        "notes",
        "source",
        "jtl_payment_id",
        "jtl_sync_status",
        "jtl_synced_at",
        "jtl_sync_error",
        "created_at"
      ],
      "requiredScope": "invoices:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/invoices/UUID/payments"
    },
    {
      "method": "POST",
      "path": "/v1/invoices/:id/payments",
      "dryRun": true,
      "idempotency": true,
      "description": "ABGESCHAFFT — antwortet immer 410 `payments_managed_in_sevdesk`. Zahlungen werden in sevDesk auf den Beleg (Voucher) gebucht; PT24 übernimmt den Zahlungsstand stündlich. Sofort spiegeln: POST /v1/legal-documents/:id/payment-status (Scope legal_documents:sync). Beleg-ID und sevDesk-Voucher: GET /v1/invoices/:id (sevdesk.voucher_id).",
      "params": [
        "dry_run (true = Vorschau, schreibt nichts)",
        "Header Idempotency-Key (optional, 24 h Replay)"
      ],
      "fields": [
        "BODY amount (number, Pflicht sofern kein mark_paid_full)",
        "BODY mark_paid_full (boolean, bucht den offenen Restbetrag)",
        "BODY payment_date (YYYY-MM-DD, Default heute)",
        "BODY payment_method (string)",
        "BODY reference (string, steuert die natürliche Idempotenz)",
        "BODY notes (string)",
        "RESPONSE data.payment.{id,invoice_id,amount,payment_date,payment_method,reference,notes,source,jtl_payment_id,jtl_sync_status,jtl_synced_at,jtl_sync_error,created_at}",
        "RESPONSE data.invoice.{id,invoice_number,status,total_amount,paid_amount,paid_date,outstanding_amount}",
        "RESPONSE meta.invoice (identisch zu data.invoice, inkl. neu gerechnetem paid_amount/outstanding_amount)",
        "RESPONSE meta.jtl_sync, meta.warnings, meta.idempotent_replay, meta.reference_auto_generated"
      ],
      "requiredScope": "invoices:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"amount\":1190.00,\"payment_date\":\"2026-08-17\",\"payment_method\":\"Überweisung\",\"reference\":\"RG-2026-1234\"}' https://api.pt24.at/v1/invoices/UUID/payments"
    },
    {
      "method": "DELETE",
      "path": "/v1/invoices/:id/payments/:paymentId",
      "dryRun": true,
      "description": "ABGESCHAFFT — antwortet immer 410 `payments_managed_in_sevdesk`. Eine Fehlbuchung wird in sevDesk zurückgenommen; der Abgleich setzt den Zahlungsstand des Belegs neu (sofort über POST /v1/legal-documents/:id/payment-status).",
      "params": [
        "dry_run (true = Vorschau, löscht nichts)"
      ],
      "fields": [
        "RESPONSE data.deleted (boolean)",
        "RESPONSE data.payment_id",
        "RESPONSE data.jtl_delete (queued|not_required)",
        "RESPONSE data.invoice.{id,invoice_number,status,total_amount,paid_amount,outstanding_amount}",
        "RESPONSE meta.warnings"
      ],
      "requiredScope": "invoices:write",
      "example": "curl -X DELETE -H 'X-API-Key: …' https://api.pt24.at/v1/invoices/UUID/payments/UUID"
    },
    {
      "method": "GET",
      "path": "/v1/legal-documents",
      "description": "Rechtsbelege (Rechnung, Anzahlung, Storno, Rechnungskorrektur) mit Nummer, Summen, PDF-Hash und sevDesk-Sync-Status. Read-only: ausgestellt wird ausschließlich in PT24, nicht über die API. Für Monitoring der Buchhaltungs-Übergabe: ?sevdesk_sync_status=failed,pending.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Belege dieses Kunden."
        },
        {
          "name": "order_id",
          "type": "uuid",
          "description": "Nur Belege dieses Auftrags."
        },
        {
          "name": "status",
          "type": "string (enum, Kommaliste)",
          "description": "legal_document_status — erlaubte Werte: draft, issued, cancelled.",
          "allowed_values": [
            "draft",
            "issued",
            "cancelled"
          ]
        },
        {
          "name": "kind",
          "type": "string (enum, Kommaliste)",
          "description": "legal_document_kind — erlaubte Werte: invoice, down_payment, credit_note, invoice_correction, invoice_cancellation.",
          "allowed_values": [
            "invoice",
            "down_payment",
            "credit_note",
            "invoice_correction",
            "invoice_cancellation"
          ]
        },
        {
          "name": "year",
          "type": "integer (YYYY)",
          "description": "Belegjahr: document_date liegt in diesem Jahr."
        },
        {
          "name": "sevdesk_sync_status",
          "type": "string (enum, Kommaliste)",
          "description": "pending, pushed, failed, skipped.",
          "allowed_values": [
            "pending",
            "pushed",
            "failed",
            "skipped"
          ]
        },
        {
          "name": "source",
          "type": "internal | jtl",
          "description": "Herkunft. internal = in PT24 ausgestellt, jtl = Altbestand-Spiegel."
        }
      ],
      "fields": [
        "id",
        "document_number",
        "kind",
        "status",
        "source",
        "customer_id",
        "order_id",
        "invoice_id",
        "cancels_document_id",
        "correction_group_id",
        "document_date",
        "due_date",
        "currency",
        "net_amount",
        "tax_amount",
        "total_amount",
        "paid_amount",
        "finalized_at",
        "pdf_available",
        "pdf_sha256",
        "sevdesk_sync_status",
        "sevdesk_voucher_id",
        "sevdesk_synced_at",
        "sevdesk_last_error",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "legal_documents:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/legal-documents?year=2026&sevdesk_sync_status=failed'"
    },
    {
      "method": "GET",
      "path": "/v1/legal-documents/:id",
      "description": "Einzelner Rechtsbeleg im selben Format wie /v1/legal-documents.",
      "params": [],
      "fields": [
        "id",
        "document_number",
        "kind",
        "status",
        "source",
        "customer_id",
        "order_id",
        "invoice_id",
        "cancels_document_id",
        "correction_group_id",
        "document_date",
        "due_date",
        "currency",
        "net_amount",
        "tax_amount",
        "total_amount",
        "paid_amount",
        "finalized_at",
        "pdf_available",
        "pdf_sha256",
        "sevdesk_sync_status",
        "sevdesk_voucher_id",
        "sevdesk_synced_at",
        "sevdesk_last_error",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "legal_documents:read"
    },
    {
      "method": "GET",
      "path": "/v1/legal-documents/:id/pdf",
      "description": "Signierte Download-URL (60 Sekunden gültig) auf das write-once-Original im Bucket legal-documents, dazu pdf_sha256 zur Gegenprobe (sha256sum der geladenen Datei muss übereinstimmen). Es wird NIE neu gerendert — die Bucket-Datei ist das Original. Belege ohne eingefrorenes PDF liefern 409 pdf_not_ready.",
      "params": [],
      "fields": [
        "document_id",
        "document_number",
        "url",
        "expires_in",
        "expires_at",
        "pdf_sha256",
        "storage_path"
      ],
      "requiredScope": "legal_documents:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/legal-documents/UUID/pdf"
    },
    {
      "method": "POST",
      "path": "/v1/legal-documents/:id/payment-status",
      "dryRun": true,
      "idempotency": true,
      "description": "Zahlungsstand eines ausgestellten Belegs melden (Push-Kanal neben dem stündlichen sevDesk-Poll, Schritt 8). sevDesk bleibt die Quelle der Wahrheit — dieser Endpunkt spiegelt nur. Der Status muss exakt zum Betrag passen (0 = unpaid, dazwischen = partially_paid, >= Forderung = paid); Referenzbetrag ist der Restbetrag (payable_total) einer Schlussrechnung mit verrechneten Anzahlungen, sonst die Belegsumme. ÜBERZAHLUNG ist erlaubt (Status paid, Audit-Warnung). Identische Werte = No-op (200, meta.unchanged=true). Nur für ausgestellte Belege (status=issued): in PT24 ausgestellte (source=internal) immer, JTL-Altbelege sobald sie in sevDesk verifiziert sind (sevdesk_verify_status=confirmed). Unterstützt Idempotency-Key (24 h) und ?dry_run=true.",
      "params": [
        "BODY paid_amount (number >= 0, Pflicht)",
        "BODY payment_status (unpaid | partially_paid | paid, Pflicht)",
        "BODY paid_at (ISO-Zeitstempel, optional — nur bei payment_status=paid wirksam; ohne Angabe setzt die API den Zeitpunkt der Vollzahlung selbst)",
        "dry_run (true = Vorschau, schreibt nichts)"
      ],
      "fields": [
        "document_id",
        "document_number",
        "payment_status",
        "paid_amount",
        "paid_at",
        "reference_total",
        "reference_source",
        "changed"
      ],
      "requiredScope": "legal_documents:sync",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -H 'Idempotency-Key: 9f1c…' -d '{\"paid_amount\":1200.00,\"payment_status\":\"paid\"}' https://api.pt24.at/v1/legal-documents/UUID/payment-status"
    },
    {
      "method": "GET",
      "path": "/v1/suppliers",
      "description": "Liste aller Lieferanten. Jeder Eintrag = volles JSON inkl. Adresse, Kategorien, Keywords, Primary-Contact und optional alle Kontakte / Stats.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "active",
          "type": "boolean",
          "description": "Nur aktive (true) oder nur deaktivierte (false) Lieferanten."
        },
        {
          "name": "category",
          "type": "string",
          "description": "Filter auf supplier_categories (enthält diesen Eintrag)."
        },
        {
          "name": "with_contacts",
          "type": "boolean",
          "description": "Default false. true = alle Kontakte einbetten."
        },
        {
          "name": "with_stats",
          "type": "boolean",
          "description": "Default false. true = product_count + last_order_at je Lieferant."
        }
      ],
      "fields": [
        "id",
        "vendor_code",
        "name",
        "website_url",
        "email",
        "phone",
        "contact_person",
        "address",
        "street",
        "zip",
        "city",
        "country",
        "supplier_categories",
        "delivery_methods",
        "keywords",
        "payment_methods",
        "default_lead_time_days",
        "notes",
        "active",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/suppliers?active=true&with_contacts=true'"
    },
    {
      "method": "GET",
      "path": "/v1/suppliers/:id",
      "description": "Einzelner Lieferant. with_contacts / with_stats default true.",
      "params": [],
      "fields": [
        "id",
        "vendor_code",
        "name",
        "website_url",
        "email",
        "phone",
        "contact_person",
        "address",
        "street",
        "zip",
        "city",
        "country",
        "supplier_categories",
        "delivery_methods",
        "keywords",
        "payment_methods",
        "default_lead_time_days",
        "notes",
        "active",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:read"
    },
    {
      "method": "GET",
      "path": "/v1/suppliers/:id/contacts",
      "description": "Alle Kontakte eines Lieferanten.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        }
      ],
      "fields": [
        "id",
        "vendor_id",
        "name",
        "role",
        "email",
        "phone",
        "preferred",
        "supplier",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:read"
    },
    {
      "method": "POST",
      "path": "/v1/suppliers",
      "description": "Neuen Lieferanten anlegen. Pflichtfelder: vendor_code (unique), name. Alle übrigen Felder optional. Response = volles Supplier-JSON. Schreibt Audit-Log.",
      "params": [],
      "fields": [
        "id",
        "vendor_code",
        "name",
        "website_url",
        "email",
        "phone",
        "contact_person",
        "address",
        "street",
        "zip",
        "city",
        "country",
        "supplier_categories",
        "delivery_methods",
        "keywords",
        "payment_methods",
        "default_lead_time_days",
        "notes",
        "active",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"vendor_code\":\"V-2026-001\",\"name\":\"Acme GmbH\",\"email\":\"info@acme.test\"}' https://api.pt24.at/v1/suppliers"
    },
    {
      "method": "PATCH",
      "path": "/v1/suppliers/:id",
      "description": "Partial-Update. Nur gesendete Felder werden geändert. vendor_code ist immutable.",
      "params": [],
      "fields": [
        "id",
        "vendor_code",
        "name",
        "website_url",
        "email",
        "phone",
        "contact_person",
        "address",
        "street",
        "zip",
        "city",
        "country",
        "supplier_categories",
        "delivery_methods",
        "keywords",
        "payment_methods",
        "default_lead_time_days",
        "notes",
        "active",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/suppliers/:id",
      "description": "Soft-Delete: setzt active=false. Vollständiges Löschen aus Datenschutzgründen nur über UI/Admin.",
      "params": [],
      "fields": [],
      "requiredScope": "suppliers:write"
    },
    {
      "method": "GET",
      "path": "/v1/supplier-contacts",
      "description": "Liste aller Lieferantenkontakte mit eingebettetem Supplier-Subset (vendor_code, name).",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "vendor_id",
          "type": "uuid",
          "description": "Nur Kontakte dieses Lieferanten."
        },
        {
          "name": "preferred",
          "type": "boolean",
          "description": "Nur bevorzugte Kontakte."
        }
      ],
      "fields": [
        "id",
        "vendor_id",
        "name",
        "role",
        "email",
        "phone",
        "preferred",
        "supplier",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:read"
    },
    {
      "method": "GET",
      "path": "/v1/supplier-contacts/:id",
      "description": "Einzelner Lieferantenkontakt mit eingebettetem Supplier-Subset.",
      "params": [],
      "fields": [
        "id",
        "vendor_id",
        "name",
        "role",
        "email",
        "phone",
        "preferred",
        "supplier",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:read"
    },
    {
      "method": "POST",
      "path": "/v1/supplier-contacts",
      "description": "Neuen Lieferantenkontakt anlegen. Pflichtfelder: vendor_id, name.",
      "params": [],
      "fields": [
        "id",
        "vendor_id",
        "name",
        "role",
        "email",
        "phone",
        "preferred",
        "supplier",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:write"
    },
    {
      "method": "PATCH",
      "path": "/v1/supplier-contacts/:id",
      "description": "Partial-Update Lieferantenkontakt.",
      "params": [],
      "fields": [
        "id",
        "vendor_id",
        "name",
        "role",
        "email",
        "phone",
        "preferred",
        "supplier",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "suppliers:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/supplier-contacts/:id",
      "description": "Hard-Delete des Lieferantenkontakts (Kontakte enthalten keine kritischen Belege).",
      "params": [],
      "fields": [],
      "requiredScope": "suppliers:write"
    },
    {
      "method": "POST",
      "path": "/v1/customers",
      "description": "Neuen Kunden anlegen. Pflicht: type (company|person), bei company zusätzlich company_name, bei person last_name. Optional: email, phone, vat_id, debtor_number, customer_number, customer_group_id, customer_category_id. Nach dem Anlegen wird ein JTL-Write-Job (jtl_write_customer) eingereiht — jtl_customer_id kommt asynchron zurück. Schreibt Audit-Log.",
      "params": [],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"type\":\"company\",\"company_name\":\"Acme GmbH\",\"email\":\"office@acme.test\"}' https://api.pt24.at/v1/customers"
    },
    {
      "method": "PATCH",
      "path": "/v1/customers/:id",
      "description": "Partial-Update Kundenstammdaten. Nur gesendete Felder ändern. Kunden mit is_locked=true werden abgelehnt (423) — Sperre vorher via /unlock aufheben. Nach Update wird ein JTL-Write-Job eingereiht.",
      "params": [],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/customers/:id",
      "description": "Archiviert einen Kunden (setzt archived_at + archived_reason). Löscht ihn NICHT physisch. Blockiert (409), wenn aktive Aufträge vorhanden.",
      "params": [],
      "fields": [],
      "requiredScope": "customers:write"
    },
    {
      "method": "POST",
      "path": "/v1/customers/:id/lock",
      "description": "Sperrt einen Kunden manuell (is_locked=true, lock_source='manual', locked_reason optional). Gesperrte Kunden können in Warenkörben und Aufträgen nicht mehr verwendet werden. Synced zu JTL.",
      "params": [],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:write"
    },
    {
      "method": "POST",
      "path": "/v1/customers/:id/unlock",
      "description": "Hebt die manuelle Sperre auf. JTL-gesperrte Kunden (lock_source='jtl') können nur über JTL entsperrt werden — Aufruf wird dann mit 409 abgewiesen.",
      "params": [],
      "fields": [
        "id",
        "customer_number",
        "jtl_customer_id",
        "type",
        "company_name",
        "first_name",
        "last_name",
        "display_name",
        "email",
        "phone",
        "vat_id",
        "debtor_number",
        "language_iso",
        "currency_iso",
        "customer_group_id",
        "customer_category_id",
        "archived_at",
        "archived_reason",
        "billing_address",
        "delivery_address",
        "addresses",
        "primary_contact",
        "contacts",
        "stats",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "customers:write"
    },
    {
      "method": "POST",
      "path": "/v1/customers/:id/contacts",
      "description": "Neuen Ansprechpartner anlegen. Pflicht: full_name ODER first_name/last_name (getrennt übergebene Namen werden unverändert gespeichert). Optional: email, phone, mobile_phone, position, department, is_primary. 409 `contact_exists` (inkl. existing_contact_id), wenn zu der E-Mail bereits ein Kontakt existiert. Löst automatisch JTL-Sync (jtl_write_customer_contact) für den Kunden aus.",
      "params": [],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"full_name\":\"Max Muster\",\"email\":\"max@acme.test\",\"is_primary\":true}' https://api.pt24.at/v1/customers/UUID/contacts"
    },
    {
      "method": "POST",
      "path": "/v1/customer-contacts",
      "description": "Alias — Kontakt anlegen mit customer_id im Body (gleiche Response wie POST /v1/customers/:id/contacts).",
      "params": [],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:write"
    },
    {
      "method": "PATCH",
      "path": "/v1/customer-contacts/:id",
      "description": "Partial-Update Kontakt. customer_id ist immutable. Löst JTL-Sync für den Kunden aus.",
      "params": [],
      "fields": [
        "id",
        "customer_id",
        "jtl_contact_id",
        "jtl_staff_name",
        "full_name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "mobile_phone",
        "position",
        "department",
        "is_primary",
        "avatar_url",
        "customer",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "contacts:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/customer-contacts/:id",
      "description": "Löscht Kontakt. Blockiert (409), wenn ein App-User an diesen Kontakt gebunden ist — App-User zuerst über Backend entfernen. Löst JTL-Sync aus.",
      "params": [],
      "fields": [],
      "requiredScope": "contacts:write"
    },
    {
      "method": "POST",
      "path": "/v1/customers/:id/addresses",
      "description": "Neue Adresse anlegen. Pflicht: type (invoice|delivery|other); bei invoice/delivery zusätzlich zip, city (JTL-Pflichtfelder, sonst 400; street ist optional). is_default=true setzt sie zusätzlich als Standard-Adresse des Typs. Löst JTL-Sync (jtl_write_customer_address) aus.",
      "params": [],
      "fields": [
        "id",
        "type",
        "company",
        "first_name",
        "last_name",
        "street",
        "house_no",
        "zip",
        "city",
        "country_code",
        "email",
        "phone",
        "is_default",
        "jtl_address_id"
      ],
      "requiredScope": "customers:write"
    },
    {
      "method": "PATCH",
      "path": "/v1/customer-addresses/:id",
      "description": "Partial-Update einer Adresse. customer_id ist immutable. Löst JTL-Sync aus.",
      "params": [],
      "fields": [],
      "requiredScope": "customers:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/customer-addresses/:id",
      "description": "Löscht Adresse. Blockiert (409), wenn sie als default_invoice_address / default_delivery_address des Kunden gesetzt oder von aktiven Aufträgen referenziert ist. Löst JTL-Sync aus.",
      "params": [],
      "fields": [],
      "requiredScope": "customers:write"
    },
    {
      "method": "GET",
      "path": "/v1/carts",
      "description": "Liste aller Warenkörbe (is_cart=true). Selbe Struktur wie /v1/orders — inkl. Kunde, Adressen, Positionen. Alias für /v1/orders?is_quote=true.",
      "params": [
        {
          "name": "updated_since",
          "type": "ISO-8601 timestamp",
          "description": "Liefert nur Datensätze mit updated_at > X. Hauptmechanismus für Delta-Sync."
        },
        {
          "name": "created_since",
          "type": "ISO-8601 timestamp",
          "description": "Filter auf created_at."
        },
        {
          "name": "id",
          "type": "uuid",
          "description": "Einzel-ID-Filter."
        },
        {
          "name": "ids",
          "type": "uuid,uuid,...",
          "description": "Mehrere IDs (Komma-Liste)."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltextsuche über Name/Nummer/E-Mail/Telefon."
        },
        {
          "name": "format",
          "type": "json | csv | 3cx | vcard",
          "description": "Ausgabeformat. Default: json. 3cx/vcard nur für /v1/customer-contacts."
        },
        {
          "name": "fields",
          "type": "feld1,feld2",
          "description": "Sparse Fieldsets — nur diese Top-Level-Felder zurückgeben (json)."
        },
        {
          "name": "sort",
          "type": "feld | -feld",
          "description": "Sortierung. Minus = absteigend. Default: -updated_at."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Max. Datensätze pro Seite. Default 100."
        },
        {
          "name": "cursor",
          "type": "opaque string",
          "description": "Cursor aus meta.next_cursor der vorherigen Antwort."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Warenkörbe dieses Kunden."
        },
        {
          "name": "status",
          "type": "string (enum)",
          "description": "order_status Filter."
        },
        {
          "name": "submitted",
          "type": "true | false | all",
          "description": "true = eingereicht (cart_submitted_at gesetzt), false = Entwurf. Default all. Wird als Bedingung in die Datenbank-Query gezogen — die Paginierung stimmt damit auch bei aktivem Filter."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/carts?limit=50'"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id",
      "description": "Einzelner Warenkorb im vollen Order-JSON-Format.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "PATCH",
      "path": "/v1/carts/:id",
      "dryRun": true,
      "description": "Kopfdaten eines Warenkorbs ändern (offer_note, Projekt, Notizen, Kontakt, Liefer-Infos, Sprache/Währung/Steuer-Modus). Ist der Warenkorb bereits als Angebot versendet, ist er eingefroren → 409 `quote_version_locked` (details.latest_version, details.open_editing_via). Erst `POST /v1/carts/:id/versions` (\"Neue Version beginnen\") gibt ihn wieder frei; der nächste Versand friert Version n+1 ein. Wenn der Warenkorb bereits als Auftrag freigegeben wurde → 409 `already_released` (dann `PATCH /v1/orders/:id` verwenden). customer_id ist nicht änderbar. Nur wirklich geänderte Felder werden geschrieben und im Audit-Log als Diff protokolliert.",
      "params": [
        {
          "name": "project_name",
          "type": "string",
          "description": "Projekt/Titel. Leerer String → wird auf null gesetzt."
        },
        {
          "name": "notes",
          "type": "string",
          "description": "⚠️ KUNDENSICHTBAR — wird in Angebots-/Auftrags-PDF und im Portal gedruckt. Für rein interne Vermerke `internal_notes` verwenden."
        },
        {
          "name": "internal_notes",
          "type": "string",
          "description": "🔒 NUR INTERN — nie kundensichtbar (kein PDF, kein Portal, kein JTL-Sync)."
        },
        {
          "name": "offer_note",
          "type": "string",
          "description": "Kundensichtbarer Hinweistext für Angebots-PDF (Kopf)."
        },
        {
          "name": "customer_reference",
          "type": "string",
          "description": "Kundenreferenz / Bestellnummer des Kunden."
        },
        {
          "name": "contact_id",
          "type": "uuid",
          "description": "Ansprechpartner beim Kunden. Muss zum Kunden des Warenkorbs gehören. null = entfernen."
        },
        {
          "name": "delivery_date",
          "type": "ISO date",
          "description": "Wunsch-Liefertermin. null = entfernen."
        },
        {
          "name": "delivery_address_id",
          "type": "uuid",
          "description": "Lieferadresse — muss zum Kunden gehören. null = entfernen."
        },
        {
          "name": "delivery_urgency",
          "type": "enum",
          "description": "ok | soon | overdue."
        },
        {
          "name": "language_iso",
          "type": "string",
          "description": "z. B. \"de\", \"en\"."
        },
        {
          "name": "currency_iso",
          "type": "string",
          "description": "z. B. \"EUR\"."
        },
        {
          "name": "tax_mode",
          "type": "string",
          "description": "Erlaubt: standard, reverse_charge (EU, Art. 196 MwSt-RL), reverse_charge_domestic (Inland-Bauleistung, § 19 Abs. 1a UStG), intra_community, tax_free_export. Aliase: reverse_charge_eu → reverse_charge, reverse_charge_at/reverse_domestic → reverse_charge_domestic. `net`/`gross` werden abgelehnt (bezeichnen keine Steuermodi)."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X PATCH -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"offer_note\":\"Preise gültig 30 Tage.\",\"project_name\":\"Messe Q3\"}' https://api.pt24.at/v1/carts/{cart_id}"
    },
    {
      "method": "PATCH",
      "path": "/v1/orders/:id",
      "dryRun": true,
      "description": "Kopfdaten eines freigegebenen Auftrags ändern (nur unkritische Felder — keine Positionen/Preise). Änderungen werden im Audit-Log als Diff protokolliert.",
      "params": [
        {
          "name": "project_name",
          "type": "string",
          "description": "Projekt/Titel."
        },
        {
          "name": "notes",
          "type": "string",
          "description": "⚠️ KUNDENSICHTBAR (siehe PATCH /v1/carts/:id)."
        },
        {
          "name": "internal_notes",
          "type": "string",
          "description": "🔒 Nur intern."
        },
        {
          "name": "offer_note",
          "type": "string",
          "description": "⚠️ KUNDENSICHTBAR — Angebots-/Auftragstext oberhalb der Positionen. Auch nach Freigabe änderbar; wirkt auf neu erzeugte Auftragsbestätigungen, nicht auf bereits ausgestellte Rechnungen."
        },
        {
          "name": "customer_reference",
          "type": "string",
          "description": "Kundenreferenz."
        },
        {
          "name": "delivery_date",
          "type": "ISO date",
          "description": "Wunsch-Liefertermin."
        },
        {
          "name": "delivery_urgency",
          "type": "enum",
          "description": "ok | soon | overdue."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X PATCH -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"internal_notes\":\"Beschaffung: Lieferant X, EK 42€\"}' https://api.pt24.at/v1/orders/{order_id}"
    },
    {
      "method": "POST",
      "path": "/v1/carts",
      "idempotency": true,
      "description": "Neuen Warenkorb anlegen. Alle Felder optional — ohne customer_id/customer_number wird der Temp-Kunde 0000000 verwendet. Positionen können direkt inline mitgegeben werden (unit_price ist Netto; total_price wird berechnet falls fehlend). Response = volles Order-JSON + Location-Header. Schreibt Audit-Log. Unterstützt `Idempotency-Key` Header (24 h TTL): identischer Body → identische Response, abweichender Body → 409 idempotency_conflict.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Bevorzugte Kundenreferenz."
        },
        {
          "name": "customer_number",
          "type": "string",
          "description": "Alternative zu customer_id — z. B. \"K-12345\" oder \"0000000\" für Temp-Kunde."
        },
        {
          "name": "project_name",
          "type": "string",
          "description": "Default \"Neuer Warenkorb\"."
        },
        {
          "name": "notes",
          "type": "string",
          "description": "⚠️ KUNDENSICHTBAR — wird im Angebots-/Auftrags-PDF und im Portal gedruckt."
        },
        {
          "name": "internal_notes",
          "type": "string",
          "description": "🔒 Rein interne Notiz — nie kundensichtbar."
        },
        {
          "name": "offer_note",
          "type": "string",
          "description": "Kundensichtbare Notiz für Angebot/PDF."
        },
        {
          "name": "customer_reference",
          "type": "string",
          "description": "Kundenreferenz / Bestellnummer des Kunden."
        },
        {
          "name": "contact_id",
          "type": "uuid",
          "description": "Ansprechpartner beim Kunden (aus GET /v1/customers/:id/contacts)."
        },
        {
          "name": "delivery_date",
          "type": "ISO date",
          "description": "Wunsch-Liefertermin."
        },
        {
          "name": "delivery_address_id",
          "type": "uuid",
          "description": "Lieferadresse — muss zum Kunden gehören (aus GET /v1/customers/:id/addresses)."
        },
        {
          "name": "delivery_urgency",
          "type": "enum",
          "description": "ok | soon | overdue."
        },
        {
          "name": "language_iso",
          "type": "string",
          "description": "z. B. \"de\", \"en\"."
        },
        {
          "name": "currency_iso",
          "type": "string",
          "description": "z. B. \"EUR\"."
        },
        {
          "name": "tax_mode",
          "type": "string",
          "description": "Erlaubt: standard, reverse_charge (EU, Art. 196 MwSt-RL), reverse_charge_domestic (Inland-Bauleistung, § 19 Abs. 1a UStG), intra_community, tax_free_export. Aliase: reverse_charge_eu, reverse_charge_at, reverse_domestic. `net`/`gross` werden abgelehnt."
        },
        {
          "name": "responsibles",
          "type": "array",
          "description": "Verantwortliche des Auftrags. Format: [{ contact_id?: uuid, staff_user_id?: uuid, role?: string }]. Genau EINES von contact_id ODER staff_user_id pro Eintrag. Kontakte müssen zum Kunden gehören; staff_user_id muss ein Platform-Staff- oder KI-Agent-User sein."
        },
        {
          "name": "positions",
          "type": "array",
          "description": "Optional. Jede Position: {description*, quantity*, unit?, unit_price? (VK netto), sales_price_gross? (VK brutto), purchase_price_net? (EK netto), tax_rate?, sku?, notes? (KUNDENSICHTBAR), internal_notes? (nur intern), discount?, is_optional?, is_discount? (Rabattzeile, nur mit negativem Betrag), variant_group?, customer_product_id?, supplier_product_id?, configuration?, sub_positions?}. * = Pflicht. **Automatische BOM**: Wenn customer_product_id gesetzt ist und das Produkt eine BOM-Vorlage hat, werden Sub-Positionen automatisch serverseitig expandiert. **Manuelle BOM**: Alternativ kann sub_positions[] direkt mitgegeben werden (max. 50 Kinder). Auf Kindern sind sub_positions, variant_group, is_optional und is_discount verboten. **Variantengruppen**: Positionen derselben variant_group MÜSSEN im Angebot direkt untereinander stehen — verstreut gesendete Gruppen werden serverseitig automatisch an die Stelle der ersten Position der Gruppe zusammengeschoben und mit dem Hinweis `variant_group_reordered` gemeldet."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"customer_number\":\"0000000\",\"project_name\":\"Designwahl\",\"positions\":[{\"description\":\"Marlenes Design 1\",\"quantity\":1,\"variant_group\":\"Designwahl\",\"sub_positions\":[{\"description\":\"Folierung\",\"quantity\":1,\"unit_price\":120},{\"description\":\"Grafik\",\"quantity\":1,\"unit_price\":80}]},{\"description\":\"Marlenes Design 3\",\"quantity\":1,\"variant_group\":\"Designwahl\",\"sub_positions\":[{\"description\":\"Folierung\",\"quantity\":1,\"unit_price\":150},{\"description\":\"Grafik\",\"quantity\":1,\"unit_price\":90}]}]}' https://api.pt24.at/v1/carts"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/positions",
      "dryRun": true,
      "description": "Weitere Positionen an bestehenden Warenkorb anhängen. Body: { positions: [...] }. Position-Nummern werden automatisch fortgezählt. Unterstützt sowohl automatische BOM (customer_product_id mit Template) als auch manuelle BOM (sub_positions[] direkt am Parent, max. 50 Kinder). Kinder erben Nummerierung direkt nach dem Parent. Ist der Warenkorb bereits als Angebot versendet, ist er eingefroren → 409 `quote_version_locked` (details.latest_version, details.open_editing_via). Erst `POST /v1/carts/:id/versions` (\"Neue Version beginnen\") gibt ihn wieder frei; der nächste Versand friert Version n+1 ein.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "PATCH",
      "path": "/v1/carts/:cart_id/positions/:position_id",
      "dryRun": true,
      "description": "Einzelne Cart-Position ändern. Änderbare Felder: description, quantity, unit, unit_price (VK netto), sales_price_gross (VK brutto), purchase_price_net (EK netto), tax_rate, sku, notes, discount, is_optional, is_discount (nur Hauptposition mit negativem Betrag), variant_group (String/null), customer_product_id, supplier_product_id, configuration. Änderungen an quantity/customer_product_id/configuration triggern eine BOM-Neuexpansion (alte Sub-Positionen werden soft-deleted). Wird variant_group gesetzt, rückt die Position automatisch an den Block ihrer Gruppe (Hinweis `variant_group_reordered`) — Gruppen stehen im Angebot immer direkt untereinander. Ist der Warenkorb bereits als Angebot versendet, ist er eingefroren → 409 `quote_version_locked` (details.latest_version, details.open_editing_via). Erst `POST /v1/carts/:id/versions` (\"Neue Version beginnen\") gibt ihn wieder frei; der nächste Versand friert Version n+1 ein.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X PATCH -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"quantity\":5}' https://api.pt24.at/v1/carts/{cart_id}/positions/{position_id}"
    },
    {
      "method": "DELETE",
      "path": "/v1/carts/:cart_id/positions/:position_id",
      "dryRun": true,
      "description": "Cart-Position soft-löschen (setzt deleted_at). Kaskadiert auf Sub-Positionen (BOM-Kinder). Ist der Warenkorb bereits als Angebot versendet, ist er eingefroren → 409 `quote_version_locked` (details.latest_version, details.open_editing_via). Erst `POST /v1/carts/:id/versions` (\"Neue Version beginnen\") gibt ihn wieder frei; der nächste Versand friert Version n+1 ein.",
      "params": [],
      "fields": [
        "id",
        "deleted_at",
        "cascaded_children"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:id/positions",
      "dryRun": true,
      "description": "Positionen an einen FREIGEGEBENEN Auftrag anhängen (gleiche Body-Struktur wie /v1/carts/:id/positions). Auftrag und Warenkorb sind derselbe Datensatz — beide Pfade werden akzeptiert, der Server entscheidet anhand von is_cart. Blocker: `order_cancelled` → 423. Warnungen (erlaubt, aber gemeldet): `order_invoiced`, `jtl_sync_queued`. Ist der Auftrag in JTL, werden die neuen Positionen automatisch als JTL-Line-Items eingereiht (meta.jtl_sync).",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"positions\":[{\"description\":\"Nachtrag Montage\",\"quantity\":1,\"unit_price\":150}]}' 'https://api.pt24.at/v1/orders/{order_id}/positions?dry_run=true'"
    },
    {
      "method": "PATCH",
      "path": "/v1/orders/:order_id/positions/:position_id",
      "dryRun": true,
      "description": "Position eines freigegebenen Auftrags ändern. Es gibt kein Feld `locked` — die Sperre ergibt sich aus mehreren Signalen. HARTE BLOCKER (423 `position_locked`): `position_delivered` (quantity_delivered > 0 — Lieferschein würde verfälscht), `position_produced` (production_status=completed bzw. completed_at gesetzt), `order_cancelled`. WARNUNGEN (Änderung erlaubt, wird aber gemeldet): `production_running` (in_progress/assigned), `bom_child` (parent_position_id gesetzt), `jtl_sync_queued`, `order_invoiced`. Mit ?dry_run=true liefert die Antwort `preconditions` mit blockers/warnings und `would_apply`, ohne zu schreiben.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X PATCH -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"quantity\":5}' 'https://api.pt24.at/v1/orders/{order_id}/positions/{position_id}?dry_run=true'"
    },
    {
      "method": "DELETE",
      "path": "/v1/orders/:order_id/positions/:position_id",
      "dryRun": true,
      "description": "Position eines freigegebenen Auftrags soft-löschen. Gleiche Blocker/Warnungen wie PATCH. Bei bereits gelieferter Ware wird NICHT gelöscht (423 `position_locked`) — eine gelöschte Zeile ist keine Korrektur, sondern ein Loch in der Buchhaltung; stattdessen Gutschrift/Korrekturrechnung erstellen. Ist der Auftrag in JTL, wird das Line-Item-Delete automatisch eingereiht.",
      "params": [],
      "fields": [
        "id",
        "deleted_at",
        "cascaded_children",
        "jtl_sync"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/submit",
      "dryRun": true,
      "description": "Warenkorb einreichen — setzt cart_submitted_at und (optional) Status. Body: { note?: string, send_confirmation?: boolean, confirmation_email?: string, confirmation_name?: string }. Optional: ?release=true wandelt den Warenkorb direkt in einen Auftrag um (is_cart=false, order_number neu aus dem PT24-Nummernkreis, cart_released_at gesetzt). Nach der Freigabe läuft der Nachlauf (Bestätigungsmail bei send_confirmation=true, interne Mail, SharePoint-Ordner) als Job order_post_release (Antwort data.post_release). Nur bei integrations.jtl_enabled=true: fehlende jtl_customer_id → 409 `customer_not_in_jtl` ohne Änderung, Nachlauf über den JTL-Import.",
      "params": [
        {
          "name": "release",
          "type": "boolean",
          "description": "true = direkt als Auftrag freigeben (überspringt Angebotsphase, generiert echte order_number)."
        }
      ],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/release",
      "dryRun": true,
      "description": "Freigabe eines bereits eingereichten und vom Kunden akzeptierten Angebots. Wandelt Cart in Auftrag um (setzt cart_released_at, is_cart=false, vergibt neue order_number aus dem PT24-Nummernkreis). Nur erlaubt wenn cart_submitted_at + cart_accepted_at gesetzt und cart_released_at noch NULL. Body optional: { note?: string, delivery_date?: string (YYYY-MM-DD), send_confirmation?: boolean (Auftragsbestätigung an den Kunden), confirmation_email?: string, confirmation_name?: string }. Antwort enthält data.jtl_sync und — bei JTL aus — data.post_release (Job order_post_release: Bestätigungsmail, interne Mail, SharePoint-Ordner). Nur bei integrations.jtl_enabled=true: fehlende jtl_customer_id → 409 `customer_not_in_jtl`, die Freigabe wird NICHT durchgeführt.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:id/jtl-sync",
      "dryRun": true,
      "description": "NUR BEI JTL AKTIV (integrations.jtl_enabled=true), sonst 410 `jtl_disabled`. Recovery-Endpoint: schiebt eine Order (is_cart=false, jtl_order_id noch NULL) nachträglich in die JTL-Sync-Queue. Heilt dabei auch Hängezustände: fehlt cart_released_at oder trägt der Datensatz noch eine WK-Nummer, werden cart_released_at gesetzt und eine echte Auftragsnummer vergeben (Feld `repaired: true` in der Antwort). Idempotent — wenn bereits eine offene salesorder_create-Session existiert, wird nichts neu angelegt. Fehlt die jtl_customer_id des Kunden → 409 `customer_not_in_jtl` mit Handlungsanweisung, ohne jeden Schreibzugriff. Body optional: { note?: string, delivery_date?: string }. Unterstützt ?dry_run=true.",
      "params": [],
      "fields": [
        "order_id",
        "order_number",
        "repaired",
        "jtl_sync"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/send",
      "description": "Angebot verschicken: friert den aktuellen Stand als unveränderliche Version ein (quote_versions, Snapshot + Angebots-PDF im Bucket quote-versions), erzeugt einen quote_share_token (30 Tage Gültigkeit, konfigurierbar) und verschickt optional E-Mails an die angegebenen Empfänger via `send-notification-email` (Template quote_offer bei Version 1, quote_revised ab Version 2). Setzt cart_visibility=both und cart_submitted_at. Ab der zweiten Version muss zuvor eine Bearbeitung eröffnet sein (`POST /v1/carts/:id/versions`), sonst 409 `quote_editing_not_open`. Wiederholter Aufruf mit `token_id` → Resend derselben Version über denselben Link (kein Einfrieren); ist dabei eine Bearbeitung offen → 409 `quote_editing_open`. Response enthält `public_url`, Token, Send-Status pro Empfänger, `mode` (new_version|resend) und `version` (Nummer, Gültigkeit, `pdf_ready`). `pdf_ready:false` heißt nur, dass das Archiv-PDF vom Job quote_version_render_pdf nachgezogen wird — die Version selbst ist eingefroren.",
      "params": [
        {
          "name": "recipients",
          "type": "array",
          "description": "Optional. Liste `[{ email: string, name?: string }]`. Wenn leer → nur Token/Link erzeugen, ohne E-Mail-Versand."
        },
        {
          "name": "custom_subject",
          "type": "string",
          "description": "Optional. Betreff überschreiben."
        },
        {
          "name": "custom_message",
          "type": "string",
          "description": "Optional. Einleitungstext für die E-Mail."
        },
        {
          "name": "expires_in_days",
          "type": "integer",
          "description": "Default 30. Gültigkeit des Links."
        },
        {
          "name": "allow_partial_acceptance",
          "type": "boolean",
          "description": "Default false. Wenn true darf der Kunde einzelne Positionen ablehnen."
        },
        {
          "name": "attach_pdf",
          "type": "boolean",
          "description": "Default true. Angebots-PDF als E-Mail-Anhang."
        },
        {
          "name": "include_terms",
          "type": "boolean",
          "description": "Default false. AGB als zusätzliches PDF anhängen."
        },
        {
          "name": "token_id",
          "type": "uuid",
          "description": "Optional. Wenn gesetzt → Resend derselben Version über den bestehenden Token (Link bleibt gleich, es wird nichts neu eingefroren)."
        },
        {
          "name": "valid_until",
          "type": "string",
          "description": "Optional. ISO-Zeitpunkt für die Gültigkeit der Version; überschreibt expires_in_days."
        },
        {
          "name": "valid_days",
          "type": "integer",
          "description": "Alias von expires_in_days."
        },
        {
          "name": "requires_prepayment",
          "type": "boolean",
          "description": "Optional. Wird vor dem Einfrieren auf den Warenkorb geschrieben und in die Version übernommen."
        },
        {
          "name": "offer_note",
          "type": "string",
          "description": "Optional. Angebotsvermerk; wird vor dem Einfrieren auf den Warenkorb geschrieben und in die Version übernommen."
        },
        {
          "name": "extra_attachments",
          "type": "array",
          "description": "Optional. Zusätzliche Mail-Anhänge `[{ name, mime_type, bytes }]` (base64) oder `[{ name, mime_type, bucket, path }]`."
        },
        {
          "name": "is_revision",
          "type": "boolean",
          "description": "Wird ignoriert. Das Mail-Template ergibt sich aus der Versionsnummer (Version 1 → quote_offer, ab Version 2 → quote_revised); die Antwort führt `is_revision` weiterhin zur Abwärtskompatibilität."
        }
      ],
      "fields": [
        "token",
        "public_url",
        "expires_at",
        "recipients_sent",
        "recipients_failed",
        "mode",
        "version"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"recipients\":[{\"email\":\"kunde@firma.at\",\"name\":\"Max Muster\"}],\"expires_in_days\":14}' https://api.pt24.at/v1/carts/{cart_id}/send"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/revoke-share",
      "description": "Alle aktiven Share-Tokens eines Angebots widerrufen (is_revoked=true). Nach dem Widerruf kann der öffentliche Link nicht mehr geöffnet werden. Idempotent — bereits widerrufene Tokens werden ignoriert.",
      "params": [
        {
          "name": "token_id",
          "type": "uuid",
          "description": "Optional. Nur diesen Token widerrufen. Ohne Parameter werden alle aktiven Tokens des Warenkorbs widerrufen."
        }
      ],
      "fields": [
        "revoked_count"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/quote-status",
      "description": "Live-Status eines Angebots für Polling: submitted_at, accepted_at, rejected_at, change_requested_at, active_share (mit access_count, download_count, accessed_at) und position-level acceptance-Zustand. Dazu der Versionsstand (Plan 4.4): `current_version` (die neueste eingefrorene Fassung inkl. pdf_available), `versions_count`, `accepted_version` (welchen Stand der Kunde angenommen hat), `editing_open` / `editing_since` (läuft gerade eine Bearbeitung? dann sind Schreibzugriffe erlaubt) und `open_change_request`. Perfekt für externe Systeme die auf Kundenreaktion warten.",
      "params": [],
      "fields": [
        "submitted_at",
        "accepted_at",
        "rejected_at",
        "change_requested_at",
        "released_at",
        "active_share",
        "positions_acceptance",
        "current_version",
        "versions_count",
        "accepted_version",
        "editing_open",
        "editing_since",
        "open_change_request"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/accept",
      "dryRun": true,
      "description": "Angebot programmatisch akzeptieren (z. B. Rückkanal aus externem CRM/Portal). Setzt cart_accepted_at, optional pro-Position acceptance_status. Body: { accepted_position_ids?: uuid[], declined_position_ids?: uuid[], note?: string, force?: boolean }. Nur bei integrations.jtl_enabled=true: hat der Kunde keine jtl_customer_id → 409 `customer_not_in_jtl` ohne Änderung (spätere Freigabe würde sonst scheitern); mit `force: true` wird trotzdem akzeptiert und die Antwort enthält eine Warnung. Unterstützt ?dry_run=true.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/reject",
      "description": "Angebot ablehnen. Setzt cart_rejected_at + optional Ablehnungsgrund in notes. Body: { reason?: string, requested_changes?: string }. Wenn `requested_changes` gesetzt → cart_change_requested_at + Message wird an internes Message-Thread angehängt.",
      "params": [],
      "fields": [
        "id",
        "order_number",
        "external_number",
        "billing_number",
        "is_quote",
        "is_cart",
        "is_cancelled",
        "status",
        "status_text",
        "payment_status",
        "jtl_sync_status",
        "source",
        "project_name",
        "customer_reference",
        "notes",
        "internal_notes",
        "offer_note",
        "language_iso",
        "currency_iso",
        "tax_mode",
        "tax_text",
        "requires_prepayment",
        "created_at",
        "updated_at",
        "sales_order_date",
        "delivery_date",
        "shipping_date",
        "delivered_date",
        "delivery_urgency",
        "customer",
        "billing_address",
        "delivery_address",
        "contact",
        "totals",
        "payment",
        "positions_count",
        "positions",
        "meta"
      ],
      "requiredScope": "orders:write"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/versions",
      "description": "Alle eingefrorenen Versionen eines Angebots, neueste zuerst. Eine Version entsteht ausschließlich beim Versand (POST /v1/carts/:id/send) und ist danach unveränderlich. `pdf_available` ist true, sobald Speicherpfad UND SHA-256 des Archiv-PDFs vorliegen. Der `frozen_snapshot` ist hier bewusst NICHT enthalten (er ist groß) — dafür GET /v1/carts/:id/versions/:no. Keyset-Paginierung über version_no absteigend.",
      "params": [
        {
          "name": "limit",
          "type": "int",
          "description": "1–1000, default 100."
        },
        {
          "name": "cursor",
          "type": "string",
          "description": "Aus meta.next_cursor."
        },
        {
          "name": "status",
          "type": "string (enum)",
          "description": "sent | superseded | accepted | rejected | expired | withdrawn."
        }
      ],
      "fields": [
        "id",
        "version_no",
        "status",
        "quote_number",
        "display_number",
        "sent_at",
        "last_sent_at",
        "valid_until",
        "totals",
        "pdf_available",
        "pdf_sha256",
        "pdf_missing_reason",
        "accepted_at",
        "accepted_by_email",
        "change_request_id",
        "previous_version_id",
        "sent_to_emails",
        "created_at"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/carts/{cart_id}/versions"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/versions/:no",
      "description": "Eine einzelne Version inklusive `frozen_snapshot` — das self-contained JSON, aus dem Portal und PDF gerendert werden (Kunde, Firma, Adressen, line_items[] mit order_position_id, totals). Genau dieser Stand wurde dem Kunden gezeigt. 404 `quote_version_not_found`, wenn es die Nummer zu diesem Angebot nicht gibt.",
      "params": [],
      "fields": [
        "id",
        "version_no",
        "status",
        "quote_number",
        "display_number",
        "sent_at",
        "valid_until",
        "totals",
        "pdf_available",
        "pdf_sha256",
        "pdf_storage_path",
        "accepted_at",
        "accepted_by_email",
        "accepted_position_ids",
        "acceptance_pdf_sha256",
        "change_request_id",
        "previous_version_id",
        "sent_to_emails",
        "snapshot_schema_version",
        "frozen_snapshot"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/carts/{cart_id}/versions/2"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/versions/:no/pdf",
      "description": "Signierte URL (60 s) auf das archivierte Angebots-PDF im Bucket quote-versions plus dessen SHA-256 zur Gegenprobe (`sha256sum` der geladenen Datei muss `pdf_sha256` ergeben). 409 `pdf_not_ready`, solange Pfad oder Hash fehlen — der Job quote_version_render_pdf zieht das PDF nach; die Version selbst ist bereits eingefroren.",
      "params": [],
      "fields": [
        "order_id",
        "version_no",
        "quote_number",
        "url",
        "expires_in",
        "expires_at",
        "pdf_sha256",
        "storage_path"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/carts/{cart_id}/versions/2/pdf"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/versions",
      "dryRun": true,
      "idempotency": true,
      "description": "„Neue Version beginnen“: gibt ein versendetes (und damit gesperrtes) Angebot zur Bearbeitung frei — ruft open_quote_editing. Danach sind PATCH /v1/carts/:id und die Positions-Endpunkte wieder erlaubt; der nächste POST /v1/carts/:id/send friert Version n+1 ein. Idempotent: ist bereits eine Bearbeitung offen, antwortet der Endpunkt mit 200 und `already_open: true`, ohne etwas zu ändern. Hat der Kunde die geltende Version angenommen, kommt 409 `quote_version_accepted` — mit `force_after_acceptance: true` wird sie ausdrücklich zurückgezogen (Status withdrawn). Unterstützt Idempotency-Key und ?dry_run=true.",
      "params": [
        {
          "name": "reason",
          "type": "string",
          "description": "Optional. Grund der Bearbeitung (landet in orders.quote_editing_reason und im Audit-Log)."
        },
        {
          "name": "change_request_id",
          "type": "uuid",
          "description": "Optional. Änderungswunsch, der die Bearbeitung auslöst. Für das Vorbelegen der Mengen besser POST /v1/carts/:id/change-requests/:crid/apply verwenden."
        },
        {
          "name": "force_after_acceptance",
          "type": "boolean",
          "description": "Default false. true = auch nach Annahme bearbeiten; die angenommene Version wird auf `withdrawn` gesetzt."
        }
      ],
      "fields": [
        "editing_open",
        "editing_since",
        "already_open",
        "latest_version",
        "next_version_no",
        "withdrawn_version_id"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"reason\":\"Menge Pos. 3 korrigiert\"}' https://api.pt24.at/v1/carts/{cart_id}/versions"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/versions/discard",
      "description": "Offene Bearbeitung verwerfen — ruft discard_quote_editing: Positionen und Angebotsfelder werden exakt aus dem frozen_snapshot der neuesten Version wiederhergestellt, während der Bearbeitung angelegte Positionen werden soft-gelöscht. Danach ist der Warenkorb wieder gesperrt. 409 `quote_editing_not_open`, wenn keine Bearbeitung läuft; 409 `no_quote_version`, wenn nie eine Version eingefroren wurde.",
      "params": [],
      "fields": [
        "order_id",
        "editing_open",
        "restored_from_version",
        "restored_positions",
        "soft_deleted_positions"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{}' https://api.pt24.at/v1/carts/{cart_id}/versions/discard"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/change-requests",
      "description": "Änderungswünsche zu einem Angebot, neueste zuerst, inklusive `items[]` (gewünschte Menge, Löschung oder Freitext je Position). Quelle ist `source`: portal (Kundenportal), api (dieser Endpunkt) oder staff. Keyset-Paginierung über created_at.",
      "params": [
        {
          "name": "limit",
          "type": "int",
          "description": "1–1000, default 100."
        },
        {
          "name": "cursor",
          "type": "string",
          "description": "Aus meta.next_cursor."
        },
        {
          "name": "status",
          "type": "string (enum)",
          "description": "open | applied | rejected | obsolete."
        }
      ],
      "fields": [
        "id",
        "order_id",
        "quote_version_id",
        "source",
        "contact_name",
        "contact_email",
        "general_comment",
        "status",
        "handled_by",
        "handled_at",
        "rejection_reason",
        "resulting_version_id",
        "order_message_id",
        "created_at",
        "items_count",
        "items"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/carts/{cart_id}/change-requests?status=open'"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/change-requests",
      "idempotency": true,
      "description": "Änderungswunsch im Namen des Kunden anlegen (source `api`) — für externe Portale/CRMs, die den Wunsch nicht über das PT24-Kundenportal aufnehmen. Legt den Wunsch samt Positionen an, hängt ihn an die angegebene (oder die aktuelle) Version und markiert das Angebot mit cart_change_requested_at/-by. Ändert KEINE Positionen — das tut erst /apply. Antwort 201 mit dem Datensatz. Unterstützt Idempotency-Key.",
      "params": [
        {
          "name": "items",
          "type": "array",
          "description": "Pflicht (leer nur zulässig, wenn general_comment gesetzt ist). Je Eintrag: { order_position_id: uuid (Pflicht, muss zu diesem Angebot gehören), change_type: \"quantity\" | \"delete\" | \"other\", requested_quantity?: number (Pflicht und > 0 bei change_type=quantity), comment?: string }."
        },
        {
          "name": "quote_version_no",
          "type": "int",
          "description": "Optional. Version, auf die sich der Wunsch bezieht. Default: die aktuelle (neueste) Version."
        },
        {
          "name": "contact_name",
          "type": "string",
          "description": "Optional. Name des Kunden-Ansprechpartners; landet auch in orders.cart_change_requested_by (Default \"API\")."
        },
        {
          "name": "contact_email",
          "type": "string",
          "description": "Optional, aber empfohlen: die Ablehnung (…/reject) verschickt ihre Begründung an genau diese Adresse."
        },
        {
          "name": "general_comment",
          "type": "string",
          "description": "Optional. Freitext zum gesamten Angebot."
        }
      ],
      "fields": [
        "id",
        "order_id",
        "quote_version_id",
        "source",
        "contact_name",
        "contact_email",
        "general_comment",
        "status",
        "created_at",
        "items_count",
        "items"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"contact_name\":\"Max Muster\",\"contact_email\":\"max@firma.at\",\"items\":[{\"order_position_id\":\"UUID\",\"change_type\":\"quantity\",\"requested_quantity\":250}]}' https://api.pt24.at/v1/carts/{cart_id}/change-requests"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/change-requests/:crid/apply",
      "description": "Änderungswunsch übernehmen — ruft apply_quote_change_request: eröffnet die Bearbeitung (wie POST /v1/carts/:id/versions) und belegt die gewünschten Mengen bzw. Löschungen aus den Items vor. change_type `other` wird bewusst NICHT automatisch umgesetzt (Freitext). Der Wunsch bleibt `open`, bis der nächste Versand ihn auf `applied` samt resulting_version_id setzt. 409 `change_request_closed`, wenn er bereits abgeschlossen ist.",
      "params": [],
      "fields": [
        "change_request_id",
        "order_id",
        "status",
        "applied_items",
        "editing",
        "editing_open"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{}' https://api.pt24.at/v1/carts/{cart_id}/change-requests/{crid}/apply"
    },
    {
      "method": "POST",
      "path": "/v1/carts/:id/change-requests/:crid/reject",
      "description": "Änderungswunsch ablehnen — ruft reject_quote_change_request. `reason` ist Pflicht (fehlt er → 400 validation_error, leer → 422 reason_required), denn er geht als E-Mail (Template quote_change_rejected) an die contact_email des Wunsches. Das Angebot bleibt unverändert auf der geltenden Version; nur die Markierung cart_change_requested_at/-by wird gelöscht. `email_sent: false` bedeutet, dass der Wunsch keine contact_email trug oder der Mailversand fehlschlug — die Ablehnung selbst ist trotzdem gebucht.",
      "params": [
        {
          "name": "reason",
          "type": "string",
          "description": "Pflicht. Begründung der Ablehnung — wird dem Kunden wörtlich zugestellt."
        }
      ],
      "fields": [
        "change_request_id",
        "order_id",
        "status",
        "handled_at",
        "rejection_reason",
        "email_sent",
        "recipient_email"
      ],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"reason\":\"Die Menge 250 ist zu diesem Preis nicht darstellbar.\"}' https://api.pt24.at/v1/carts/{cart_id}/change-requests/{crid}/reject"
    },
    {
      "method": "GET",
      "path": "/v1/messages",
      "description": "Globale Nachrichten-Suche/Feed über alle Aufträge. Filterbar nach Zeitraum, Kunde, Auftrag, Autor, intern/extern, Nachrichtentyp und Volltext. Keyset-Paginierung via ?cursor.",
      "params": [
        {
          "name": "from",
          "type": "ISO timestamp",
          "description": "created_at >= from"
        },
        {
          "name": "to",
          "type": "ISO timestamp",
          "description": "created_at <= to"
        },
        {
          "name": "order_id",
          "type": "uuid",
          "description": "Nur Nachrichten dieses Auftrags."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Nachrichten zu Aufträgen dieses Kunden."
        },
        {
          "name": "author_user_id",
          "type": "uuid",
          "description": "Nur Nachrichten dieses Autors."
        },
        {
          "name": "is_internal",
          "type": "boolean",
          "description": "true = nur interne, false = nur externe Nachrichten."
        },
        {
          "name": "message_type",
          "type": "string (enum)",
          "description": "chat | system | file_comment | change_request | rejection"
        },
        {
          "name": "q",
          "type": "string",
          "description": "Volltext (ILIKE) über body."
        },
        {
          "name": "limit",
          "type": "int",
          "description": "1–200, default 50."
        },
        {
          "name": "cursor",
          "type": "base64",
          "description": "Von meta.next_cursor der vorherigen Antwort."
        }
      ],
      "fields": [
        "id",
        "order_id",
        "message_type",
        "user_id",
        "user_name",
        "body",
        "attachments",
        "mentions",
        "is_internal",
        "parent_message_id",
        "created_at"
      ],
      "requiredScope": "messages:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/messages?from=2026-07-01T00:00:00Z&q=Freigabe&limit=100'"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/messages",
      "description": "Alle Nachrichten eines Auftrags, threaded (replies eingebettet unter parent).",
      "params": [],
      "fields": [
        "id",
        "message_type",
        "body",
        "user_id",
        "user_name",
        "is_internal",
        "replies",
        "created_at"
      ],
      "requiredScope": "messages:read"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:id/messages",
      "description": "Neue Nachricht anlegen. Body: { body: string, message_type?: chat|system|file_comment|change_request|rejection, is_internal?: boolean, parent_message_id?: uuid, mentions?: any[], attachments?: Anhang[], notify?: boolean }. Anhänge müssen dauerhaft abgelegt sein: { order_file_id: uuid } oder { file_name: \"Datei.pdf\" } (wird gegen die Dateien des Auftrags aufgelöst) oder { url: \"https://…\" }. Flüchtige Transfer-Referenzen (fileRef, 48h TTL) werden mit 400 attachment_not_persisted abgelehnt. Vier-Schritt-Weg: 1) Datei in den Auftragsordner ablegen, 2) Datei/Dateiname ermitteln, 3) Nachricht mit order_file_id anlegen, 4) Ergebnis prüfen. Autor wird aus X-Acting-User Header (UUID oder E-Mail) oder dem am Key hinterlegten acting_user_id gezogen. Schreibt Audit-Log mit actor_user_id. E-Mail-Benachrichtigung läuft automatisch wie in der Oberfläche (intern → order_message_internal, extern → order_message_external, inkl. Erwähnungen und Verantwortlichen); mit notify:false unterdrückbar. Antwort enthält notification: sent|skipped|failed — ein fehlgeschlagener Versand lässt die Nachricht bestehen.",
      "params": [],
      "fields": [],
      "requiredScope": "messages:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'X-Acting-User: max@pt24.at' -H 'Content-Type: application/json' -d '{\"body\":\"Bitte prüfen\",\"is_internal\":true}' https://api.pt24.at/v1/orders/UUID/messages"
    },
    {
      "method": "GET",
      "path": "/v1/messages/:id",
      "description": "Einzelne Nachricht.",
      "params": [],
      "fields": [],
      "requiredScope": "messages:read"
    },
    {
      "method": "PATCH",
      "path": "/v1/messages/:id",
      "description": "Nachricht bearbeiten (nur eigene: acting_user muss der Autor sein). Bearbeitbare Felder: body, is_internal, attachments, mentions, notify. attachments wird wie beim Anlegen validiert (order_file_id | file_name | https-URL; fileRef wird abgelehnt). Kommen neue Erwähnungen dazu, wird message_edited nur an die neu erwähnten Personen verschickt (mit notify:false unterdrückbar). Antwort enthält notification: sent|skipped|failed.",
      "params": [],
      "fields": [],
      "requiredScope": "messages:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/messages/:id",
      "description": "Nachricht löschen (nur eigene).",
      "params": [],
      "fields": [],
      "requiredScope": "messages:write"
    },
    {
      "method": "GET",
      "path": "/v1/staff",
      "description": "Interne Benutzer (Platform-Staff, Admins, KI-Agenten), die als Verantwortliche oder Positions-Bearbeiter zugewiesen werden können. Liefert die `user_id`, die in `staff_user_id` verwendet wird.",
      "params": [
        {
          "name": "q",
          "type": "string",
          "description": "Freitextsuche über Name und E-Mail."
        },
        {
          "name": "role",
          "type": "string",
          "description": "Filter auf Rolle (z. B. platform_staff, ki_agent)."
        }
      ],
      "fields": [
        "user_id",
        "display_name",
        "email",
        "role",
        "avatar_url"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/staff"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/responsibles",
      "description": "Verantwortliche eines Auftrags/Warenkorbs auflisten (interne Staff-User und/oder Kundenkontakte).",
      "params": [],
      "fields": [
        "id",
        "role",
        "staff_user",
        "contact",
        "created_at"
      ],
      "requiredScope": "orders:read"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:id/responsibles",
      "dryRun": true,
      "description": "Verantwortlichen hinzufügen. Genau EINES von contact_id ODER staff_user_id angeben. Funktioniert für Warenkörbe und freigegebene Aufträge. Löst dieselbe Benachrichtigungs-E-Mail aus wie die Oberfläche. Unterstützt dry_run.",
      "params": [
        {
          "name": "contact_id",
          "type": "uuid",
          "description": "Kundenkontakt (muss zum Kunden des Auftrags gehören)."
        },
        {
          "name": "staff_user_id",
          "type": "uuid",
          "description": "Interner Benutzer aus GET /v1/staff."
        },
        {
          "name": "role",
          "type": "string",
          "description": "Rolle, Standard: responsible."
        }
      ],
      "fields": [],
      "requiredScope": "orders:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"staff_user_id\":\"…\",\"role\":\"responsible\"}' https://api.pt24.at/v1/orders/{order_id}/responsibles"
    },
    {
      "method": "DELETE",
      "path": "/v1/orders/:id/responsibles/:responsible_id",
      "dryRun": true,
      "description": "Verantwortlichen entfernen. Löst die Entfernt-Benachrichtigung aus. Unterstützt dry_run.",
      "params": [],
      "fields": [],
      "requiredScope": "orders:write"
    },
    {
      "method": "PUT",
      "path": "/v1/orders/:order_id/positions/:position_id/assignee",
      "dryRun": true,
      "description": "Haupt-Bearbeiter einer Position setzen oder freigeben. Body { \"staff_user_id\": \"…\" } weist zu, { \"staff_user_id\": null } gibt die Position frei. Setzt production_status auf assigned bzw. zurück auf pending. Unterstützt dry_run.",
      "params": [
        {
          "name": "staff_user_id",
          "type": "uuid|null",
          "description": "Interner Benutzer oder null zum Freigeben."
        }
      ],
      "fields": [],
      "requiredScope": "orders:write"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:order_id/positions/:position_id/helpers",
      "dryRun": true,
      "description": "Zusätzlichen Helfer zu einer Position hinzufügen. Body { \"staff_user_id\": \"…\", \"role\": \"worker|lead\" }. Unterstützt dry_run.",
      "params": [
        {
          "name": "staff_user_id",
          "type": "uuid",
          "description": "Interner Benutzer."
        },
        {
          "name": "role",
          "type": "string",
          "description": "worker (Standard) oder lead."
        }
      ],
      "fields": [],
      "requiredScope": "orders:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/orders/:order_id/positions/:position_id/helpers/:staff_user_id",
      "dryRun": true,
      "description": "Helfer von einer Position entfernen (der Haupt-Bearbeiter kann so nicht entfernt werden — dafür PUT …/assignee mit null). Unterstützt dry_run.",
      "params": [],
      "fields": [],
      "requiredScope": "orders:write"
    },
    {
      "method": "GET",
      "path": "/v1/legal-terms",
      "description": "Alle Rechtstexte exportieren (AGB, Produktion, Lieferung, Montage, Grafik/Nutzungsrechte, B2C-Fernabsatz, Freigabe, Storno, Impressum, Datenschutz). Struktur je Kategorie: { key, title, sections:[{title, content}] } — identisch zum Editor in den Einstellungen und zur AGB-PDF/Portal-Ausgabe. Mit ?language=en|it die Übersetzung. Der Rückfall auf Deutsch läuft JE KATEGORIE: translated=false heißt, diese Kategorie ist noch deutsch, auch wenn language=en angefragt wurde.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Gewünschte Sprachfassung; Standard ist de.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        }
      ],
      "fields": [
        "key",
        "title",
        "sections",
        "translated",
        "source_language"
      ],
      "requiredScope": "legal_terms:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/legal-terms?language=en'"
    },
    {
      "method": "GET",
      "path": "/v1/legal-terms/:category",
      "description": "Eine Kategorie exportieren. Gültige Keys: agb, production, delivery, assembly, design_rights, b2c_fernabsatz, approval, cancellation, imprint, privacy. Mit ?language=en|it die Übersetzung; translated=false heißt, es kommt die deutsche Fassung zurück.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Gewünschte Sprachfassung; Standard ist de.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        }
      ],
      "fields": [
        "key",
        "title",
        "sections",
        "translated",
        "source_language"
      ],
      "requiredScope": "legal_terms:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/legal-terms/agb?language=en'"
    },
    {
      "method": "PUT",
      "path": "/v1/legal-terms",
      "dryRun": true,
      "description": "Rechtstexte importieren (Teil-Update). Body entweder { \"agb\": {title, sections}, … } oder { \"categories\": [{key, title, sections}] }. Nicht mitgeschickte Kategorien bleiben unverändert; eine mitgeschickte Kategorie wird komplett ersetzt. Jede Änderung landet im Audit-Log. Unterstützt ?dry_run=true. Mit ?language=en|it wird die ÜBERSETZUNG geschrieben, das deutsche Original bleibt unberührt — so werden EN/IT-Fassungen von AGB, Impressum und Datenschutz gepflegt, ohne die Oberfläche zu öffnen.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Zu schreibende Sprachfassung; Standard ist de. en/it schreiben nur die Übersetzung.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        },
        {
          "name": "dry_run",
          "type": "boolean",
          "description": "true prüft den vollständigen Aufruf, ohne Änderungen zu speichern."
        }
      ],
      "fields": [],
      "requiredScope": "legal_terms:write",
      "example": "curl -X PUT -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"agb\":{\"title\":\"AGB\",\"sections\":[{\"title\":\"§1 Geltung\",\"content\":\"…\"}]}}' 'https://api.pt24.at/v1/legal-terms?dry_run=true'"
    },
    {
      "method": "PUT",
      "path": "/v1/legal-terms/:category",
      "dryRun": true,
      "description": "Eine Kategorie komplett ersetzen. Body { title, sections:[{title, content}] }. Unterstützt ?dry_run=true. Mit ?language=en|it wird die Übersetzung dieser Kategorie geschrieben, das deutsche Original bleibt unberührt.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Zu schreibende Sprachfassung; Standard ist de. en/it schreiben nur die Übersetzung.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        },
        {
          "name": "dry_run",
          "type": "boolean",
          "description": "true prüft den vollständigen Aufruf, ohne Änderungen zu speichern."
        }
      ],
      "fields": [],
      "requiredScope": "legal_terms:write",
      "example": "curl -X PUT -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"title\":\"General Terms and Conditions\",\"sections\":[]}' 'https://api.pt24.at/v1/legal-terms/agb?language=en'"
    },
    {
      "method": "GET",
      "path": "/v1/tax-notes",
      "description": "Firmenweite Steuerhinweise lesen: reverse_charge, intra_community, tax_free_export, small_business. text ist der wirksame Wortlaut (leer = fester Standardtext der Beleg-Erzeugung), maintained=false heißt, für diese Sprache ist nichts gepflegt. Mit ?language=en|it die Übersetzung, Rückfall je Steuerfall auf Deutsch.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Gewünschte Sprachfassung; Standard ist de.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        }
      ],
      "fields": [
        "key",
        "text",
        "maintained",
        "source_language"
      ],
      "requiredScope": "legal_terms:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/tax-notes?language=en'"
    },
    {
      "method": "PUT",
      "path": "/v1/tax-notes",
      "dryRun": true,
      "description": "Steuerhinweise schreiben (Teil-Update). Body { \"reverse_charge\": \"…\" } oder { \"notes\": [{key, text}] }. Nicht mitgeschickte Steuerfälle bleiben unverändert; leerer Text stellt auf den Standardwortlaut zurück. Mit ?language=en|it wird nur die Übersetzung geschrieben. Jede Änderung landet im Audit-Log. Bereits ausgestellte Belege bleiben unverändert.",
      "params": [
        {
          "name": "language",
          "type": "string",
          "description": "Zu schreibende Sprachfassung; Standard ist de.",
          "allowed_values": [
            "de",
            "en",
            "it"
          ]
        },
        {
          "name": "dry_run",
          "type": "boolean",
          "description": "true prüft den vollständigen Aufruf, ohne Änderungen zu speichern."
        }
      ],
      "fields": [],
      "requiredScope": "legal_terms:write",
      "example": "curl -X PUT -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"reverse_charge\":\"Steuerschuldnerschaft des Leistungsempfängers (§ 19 Abs. 1a UStG)\"}' 'https://api.pt24.at/v1/tax-notes'"
    },
    {
      "method": "GET",
      "path": "/v1/translations",
      "description": "Überblick: welche Vorlage und welcher Rechtstext in welcher Sprache gepflegt ist und was fehlt. Deckt email_templates, pdf_templates und die Rechtstexte in einem Aufruf ab — der Einstieg für alles, was Übersetzungen verwaltet.",
      "params": [
        "language (nur diese Zielsprache prüfen: en|it)"
      ],
      "fields": [
        "languages",
        "email_templates",
        "pdf_templates",
        "legal_terms",
        "summary"
      ],
      "requiredScope": "templates:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/translations?language=en'"
    },
    {
      "method": "GET",
      "path": "/v1/email-templates",
      "description": "Alle E-Mail-Vorlagen einer Sprache. Ohne ?language= die deutschen. Kundengerichtete Vorlagen sind mit customer_facing=true markiert — nur die werden übersetzt, interne Meldungen bleiben deutsch.",
      "params": [
        "language (de|en|it, Standard de)",
        "customer_facing (true = nur Kundenvorlagen)"
      ],
      "fields": [
        "type_key",
        "language",
        "name",
        "subject_template",
        "blocks",
        "is_active",
        "customer_facing"
      ],
      "requiredScope": "templates:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/email-templates?language=de&customer_facing=true'"
    },
    {
      "method": "GET",
      "path": "/v1/email-templates/:type_key",
      "description": "Eine Vorlage in einer Sprache. Gibt es sie dort nicht, kommt 404 mit dem Hinweis, dass beim Versand die deutsche Fassung greift — die Lücke bleibt sichtbar statt still.",
      "params": [
        "language (de|en|it, Standard de)"
      ],
      "fields": [
        "type_key",
        "language",
        "name",
        "subject_template",
        "blocks",
        "is_active"
      ],
      "requiredScope": "templates:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/email-templates/quote_offer?language=en'"
    },
    {
      "method": "PUT",
      "path": "/v1/email-templates/:type_key",
      "dryRun": true,
      "description": "Übersetzung schreiben oder anlegen. Body { subject_template?, blocks? }. GEPRÜFT WIRD GEGEN DIE DEUTSCHE FASSUNG: dieselben Platzhalter in derselben Häufigkeit, dieselben Bausteine in derselben Art. Verstöße werden abgewiesen, nicht gewarnt. Die deutsche Fassung (language=de) ist das Original und lässt sich hier nicht schreiben. Unterstützt ?dry_run=true.",
      "params": [
        "language (en|it, Pflicht)",
        "dry_run (true = Vorschau, schreibt nichts)"
      ],
      "fields": [],
      "requiredScope": "templates:write",
      "example": "curl -X PUT -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"subject_template\":\"Your quote {{quote_number}}\"}' 'https://api.pt24.at/v1/email-templates/quote_offer?language=en&dry_run=true'"
    },
    {
      "method": "GET",
      "path": "/v1/pdf-templates",
      "description": "Alle PDF-Vorlagen einer Sprache (Angebot, Auftragsbestätigung, Lieferschein, Rechnung, AGB).",
      "params": [
        "language (de|en|it, Standard de)"
      ],
      "fields": [
        "type_key",
        "language",
        "name",
        "header_text",
        "footer_text",
        "layout_json",
        "is_active"
      ],
      "requiredScope": "templates:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/pdf-templates?language=de'"
    },
    {
      "method": "GET",
      "path": "/v1/pdf-templates/:type_key",
      "description": "Eine PDF-Vorlage in einer Sprache. Gültige Keys: quote, order_confirmation, delivery_note, invoice, terms.",
      "params": [
        "language (de|en|it, Standard de)"
      ],
      "fields": [
        "type_key",
        "language",
        "name",
        "header_text",
        "footer_text",
        "layout_json"
      ],
      "requiredScope": "templates:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/pdf-templates/invoice?language=it'"
    },
    {
      "method": "PUT",
      "path": "/v1/pdf-templates/:type_key",
      "dryRun": true,
      "description": "Übersetzung einer PDF-Vorlage schreiben oder anlegen. Body { header_text?, footer_text?, layout_json? }. GEPRÜFT WIRD GEGEN DIE DEUTSCHE FASSUNG: Platzhalter, dieselben Layout-Elemente, und die Geometrie muss unverändert bleiben (x, y, Breite, Schriftgröße, Farben). Nur Texte dürfen sich ändern. Unterstützt ?dry_run=true.",
      "params": [
        "language (en|it, Pflicht)",
        "dry_run (true = Vorschau, schreibt nichts)"
      ],
      "fields": [],
      "requiredScope": "templates:write",
      "example": "curl -X PUT -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"header_text\":\"INVOICE\"}' 'https://api.pt24.at/v1/pdf-templates/invoice?language=en&dry_run=true'"
    },
    {
      "method": "GET",
      "path": "/v1/space-zones",
      "description": "Zonen der Centerplanung (Ebenen/Bereiche eines Centers). Filter: customer_id.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Zonen dieses Kunden."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Default 100."
        },
        {
          "name": "offset",
          "type": "integer",
          "description": "Paging-Versatz, siehe meta.next_offset."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "name",
        "color_hex",
        "description",
        "sort_order",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "spaces:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/space-zones?customer_id=UUID'"
    },
    {
      "method": "GET",
      "path": "/v1/space-zones/:id",
      "description": "Einzelne Zone.",
      "params": [],
      "requiredScope": "spaces:read"
    },
    {
      "method": "GET",
      "path": "/v1/advertising-spaces",
      "description": "Werbeflächen inkl. Hierarchie (parent_space_id, child_space_ids), Zone, Druckvorgaben (print_spec), Maßen/Konfiguration (config), Foto- und Vorlagenlink.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Flächen dieses Kunden."
        },
        {
          "name": "zone_id",
          "type": "uuid",
          "description": "Nur Flächen dieser Zone."
        },
        {
          "name": "parent_space_id",
          "type": "uuid | null",
          "description": "Unterflächen einer Fläche; 'null' = nur oberste Ebene."
        },
        {
          "name": "active",
          "type": "boolean",
          "description": "Nur aktive bzw. nur inaktive Flächen."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Suche über Name, Standort-Code und Beschreibung."
        },
        {
          "name": "updated_since",
          "type": "ISO-8601",
          "description": "Delta-Sync."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Default 100."
        },
        {
          "name": "offset",
          "type": "integer",
          "description": "Paging-Versatz."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "zone_id",
        "zone",
        "parent_space_id",
        "child_space_ids",
        "name",
        "location_code",
        "description",
        "photo_url",
        "template_url",
        "print_spec",
        "config",
        "tags",
        "active",
        "sort_order",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "spaces:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/advertising-spaces?customer_id=UUID&active=true'"
    },
    {
      "method": "GET",
      "path": "/v1/advertising-spaces/:id",
      "description": "Einzelne Werbefläche inkl. ancestor_space_ids und descendant_space_ids.",
      "params": [],
      "requiredScope": "spaces:read"
    },
    {
      "method": "GET",
      "path": "/v1/space-availability",
      "description": "Belegung/Verfügbarkeit je Werbefläche in einem Zeitraum. Eine Fläche gilt als belegt, wenn sie selbst, eine übergeordnete oder eine untergeordnete Fläche gebucht ist (gleiche Logik wie der Belegungskalender).",
      "params": [
        {
          "name": "from",
          "type": "YYYY-MM-DD",
          "description": "Pflicht. Beginn des Zeitraums."
        },
        {
          "name": "to",
          "type": "YYYY-MM-DD",
          "description": "Pflicht. Ende des Zeitraums (inklusive)."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Empfohlen — schränkt auf ein Center ein."
        },
        {
          "name": "zone_id",
          "type": "uuid",
          "description": "Nur Flächen dieser Zone."
        },
        {
          "name": "active",
          "type": "boolean",
          "description": "Default true (nur aktive Flächen)."
        }
      ],
      "fields": [
        "advertising_space_id",
        "name",
        "customer_id",
        "zone_id",
        "parent_space_id",
        "available",
        "blocking_bookings"
      ],
      "requiredScope": "spaces:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/space-availability?customer_id=UUID&from=2026-10-01&to=2026-10-31'"
    },
    {
      "method": "GET",
      "path": "/v1/space-campaigns",
      "description": "Kampagnen der Centerplanung. Mit ?from/&to oder ?with_bookings=true werden die Buchungen eingebettet; from/to filtert auf Kampagnen mit Buchungen im Zeitraum.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Kampagnen dieses Kunden."
        },
        {
          "name": "campaign_type",
          "type": "string",
          "description": "Filter auf den Kampagnentyp."
        },
        {
          "name": "from",
          "type": "YYYY-MM-DD",
          "description": "Kampagnen mit Buchungen ab diesem Datum."
        },
        {
          "name": "to",
          "type": "YYYY-MM-DD",
          "description": "Kampagnen mit Buchungen bis zu diesem Datum."
        },
        {
          "name": "with_bookings",
          "type": "boolean",
          "description": "Buchungen einbetten."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Suche über den Titel."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "title",
        "color",
        "campaign_type",
        "config",
        "bookings",
        "bookings_count",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "spaces:read"
    },
    {
      "method": "GET",
      "path": "/v1/space-campaigns/:id",
      "description": "Einzelne Kampagne inkl. Buchungen und verknüpfter Aufträge (order_links).",
      "params": [],
      "requiredScope": "spaces:read"
    },
    {
      "method": "POST",
      "path": "/v1/space-campaigns",
      "dryRun": true,
      "idempotency": true,
      "description": "Kampagne anlegen, optional gleich mit Buchungen. Bei Konflikt antwortet die API 409 booking_conflict mit den kollidierenden Buchungen — es wird nichts angelegt. Unterstützt ?dry_run=true und Idempotency-Key.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid (Pflicht)",
          "description": "Kunde/Center."
        },
        {
          "name": "title",
          "type": "string (Pflicht)",
          "description": "Kampagnentitel."
        },
        {
          "name": "campaign_type",
          "type": "string",
          "description": "Typ der Kampagne."
        },
        {
          "name": "color",
          "type": "string",
          "description": "Farbe für den Kalender."
        },
        {
          "name": "bookings[]",
          "type": "array",
          "description": "{ advertising_space_id, start_date, end_date, notes }"
        }
      ],
      "requiredScope": "spaces:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"customer_id\":\"UUID\",\"title\":\"Herbstaktion\",\"bookings\":[{\"advertising_space_id\":\"UUID\",\"start_date\":\"2026-10-01\",\"end_date\":\"2026-10-14\"}]}' https://api.pt24.at/v1/space-campaigns"
    },
    {
      "method": "GET",
      "path": "/v1/space-bookings",
      "description": "Buchungen (Belegungen) mit eingebetteter Fläche und Kampagne. Zeitraumfilter from/to liefert alle Buchungen, die den Zeitraum berühren.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Buchungen auf Flächen dieses Kunden."
        },
        {
          "name": "advertising_space_id",
          "type": "uuid",
          "description": "Nur diese Fläche."
        },
        {
          "name": "campaign_id",
          "type": "uuid",
          "description": "Nur diese Kampagne."
        },
        {
          "name": "from",
          "type": "YYYY-MM-DD",
          "description": "Buchungen, die ab diesem Datum laufen."
        },
        {
          "name": "to",
          "type": "YYYY-MM-DD",
          "description": "Buchungen, die bis zu diesem Datum beginnen."
        }
      ],
      "fields": [
        "id",
        "advertising_space_id",
        "advertising_space",
        "campaign_id",
        "campaign",
        "start_date",
        "end_date",
        "notes",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "spaces:read"
    },
    {
      "method": "GET",
      "path": "/v1/space-bookings/:id",
      "description": "Einzelne Buchung.",
      "params": [],
      "requiredScope": "spaces:read"
    },
    {
      "method": "POST",
      "path": "/v1/space-bookings",
      "dryRun": true,
      "idempotency": true,
      "description": "Buchung anlegen (Fläche + Zeitraum + bestehende Kampagne). Prüft Überschneidungen inkl. über- und untergeordneter Flächen und antwortet bei Konflikt 409 booking_conflict. Unterstützt ?dry_run=true und Idempotency-Key.",
      "params": [
        {
          "name": "advertising_space_id",
          "type": "uuid (Pflicht)",
          "description": "Zu buchende Fläche."
        },
        {
          "name": "campaign_id",
          "type": "uuid (Pflicht)",
          "description": "Kampagne, zu der die Buchung gehört."
        },
        {
          "name": "start_date",
          "type": "YYYY-MM-DD (Pflicht)",
          "description": "Beginn."
        },
        {
          "name": "end_date",
          "type": "YYYY-MM-DD (Pflicht)",
          "description": "Ende (inklusive)."
        },
        {
          "name": "notes",
          "type": "string",
          "description": "Notiz zur Buchung."
        }
      ],
      "requiredScope": "spaces:write"
    },
    {
      "method": "PATCH",
      "path": "/v1/space-bookings/:id",
      "dryRun": true,
      "description": "Zeitraum oder Notiz einer Buchung ändern. Konfliktprüfung wie bei der Anlage. Unterstützt ?dry_run=true.",
      "params": [
        {
          "name": "start_date",
          "type": "YYYY-MM-DD",
          "description": "Neuer Beginn."
        },
        {
          "name": "end_date",
          "type": "YYYY-MM-DD",
          "description": "Neues Ende."
        },
        {
          "name": "notes",
          "type": "string | null",
          "description": "Neue Notiz."
        }
      ],
      "requiredScope": "spaces:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/space-bookings/:id",
      "dryRun": true,
      "description": "Buchung stornieren (entfernt die Belegung). Unterstützt ?dry_run=true.",
      "params": [],
      "requiredScope": "spaces:write"
    },
    {
      "method": "GET",
      "path": "/v1/carts/:id/files",
      "description": "Dateien eines Warenkorbs oder Angebots — identisch zu /v1/orders/:id/files. Dateien können bereits in der Angebotsphase abgelegt werden; sie bleiben nach der Auftragsfreigabe erhalten.",
      "params": [
        "file_category"
      ],
      "fields": [
        "id",
        "file_name",
        "file_size",
        "mime_type",
        "file_category",
        "folder_type",
        "version",
        "content_sha256",
        "storage",
        "download_endpoint"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/carts/UUID/files"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/sharepoint",
      "description": "Zustand des SharePoint-Projektordners: Phase (cart/quote/order), Ist-Name, erwarteter Name und die IDs der fünf Unterordner. Gilt gleichermaßen unter /v1/carts/:id/sharepoint.",
      "params": [],
      "fields": [
        "phase",
        "number",
        "folders_ready",
        "project_folder_id",
        "project_folder_url",
        "project_folder_name",
        "expected_folder_name",
        "name_up_to_date",
        "subfolders",
        "synced_at"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/carts/UUID/sharepoint"
    },
    {
      "method": "POST",
      "path": "/v1/orders/:id/ensure-folders",
      "description": "Legt die Ordnerstruktur an oder benennt den vorhandenen Projektordner auf die aktuelle Phase um (Warenkorb WK-… → Angebot AN-… → Auftrag 5727). Idempotent; die Ordner-ID und alle Dateien bleiben erhalten. Gilt gleichermaßen unter /v1/carts/:id/ensure-folders.",
      "params": [],
      "fields": [
        "status",
        "project_folder_name",
        "expected_folder_name",
        "note"
      ],
      "requiredScope": "files:write",
      "example": "curl -X POST -H 'X-API-Key: …' https://api.pt24.at/v1/carts/UUID/ensure-folders"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/documents",
      "description": "Übersicht aller PDFs zu einem Auftrag: Angebotsversionen, Auftragsbestätigung, Lieferscheine und Rechtsbelege (Rechnung, Anzahlung, Gutschrift, Storno, Korrektur). Jeder Eintrag nennt den passenden Abruf-Pfad in pdf_endpoint. Ein Aufruf genügt, um zu wissen, was abrufbar ist.",
      "params": [],
      "fields": [
        "type",
        "id",
        "version_no",
        "number",
        "date",
        "amount",
        "status",
        "file_name",
        "file_size",
        "pdf_available",
        "pdf_sha256",
        "pdf_endpoint"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/orders/UUID/documents"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/delivery-notes",
      "description": "Alle abgelegten Lieferscheine eines Auftrags, neueste zuerst. Es wird nie neu gezeichnet — die abgelegte Datei ist das Original.",
      "params": [],
      "fields": [
        "id",
        "order_id",
        "file_name",
        "file_size",
        "created_at",
        "pdf_available",
        "pdf_endpoint"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/orders/UUID/delivery-notes"
    },
    {
      "method": "GET",
      "path": "/v1/delivery-notes/:id/pdf",
      "description": "Signierte Download-URL (60 Sekunden gültig) auf den abgelegten Lieferschein plus SHA-256 zur Gegenprobe. Ohne abgelegte Datei: 409 pdf_not_ready.",
      "params": [],
      "fields": [
        "delivery_note_id",
        "order_id",
        "file_name",
        "url",
        "expires_in",
        "expires_at",
        "pdf_sha256",
        "storage_path"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/delivery-notes/UUID/pdf"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/order-confirmation/pdf",
      "description": "Auftragsbestätigung als signierte Download-URL (60 Sekunden). Beim ersten Abruf wird sie über dieselbe Vorlagen-Engine wie in der Oberfläche erzeugt, im Bucket orders abgelegt und in order_documents registriert; jeder weitere Abruf liefert exakt dieselbe Datei. ?refresh=true erzwingt eine Neuerzeugung (neue Datei, alte bleibt erhalten). Warenkörbe liefern 409 not_released.",
      "params": [
        {
          "name": "refresh",
          "type": "boolean",
          "description": "true = neu erzeugen statt vorhandene Datei liefern."
        }
      ],
      "fields": [
        "order_id",
        "order_number",
        "document_id",
        "file_name",
        "generated_at",
        "regenerated",
        "url",
        "expires_in",
        "expires_at",
        "pdf_sha256",
        "storage_path"
      ],
      "requiredScope": "orders:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/orders/UUID/order-confirmation/pdf"
    },
    {
      "method": "GET",
      "path": "/v1/orders/:id/files",
      "description": "Alle im Auftrag abgelegten Dateien (Druckdaten, Kundenuploads, Anhänge) ohne gelöschte. Filter: file_category. storage zeigt, ob die Datei im Bucket oder in SharePoint liegt; download_endpoint liefert den Abruf-Pfad.",
      "params": [
        {
          "name": "file_category",
          "type": "string",
          "description": "Nur Dateien dieser Kategorie."
        }
      ],
      "fields": [
        "id",
        "order_id",
        "file_name",
        "file_size",
        "file_type",
        "mime_type",
        "file_category",
        "folder_type",
        "version",
        "content_sha256",
        "created_at",
        "created_by",
        "storage",
        "download_endpoint"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/orders/UUID/files"
    },
    {
      "method": "GET",
      "path": "/v1/order-files/:id/download",
      "description": "Download-Link zu einer Auftragsdatei. Bucket-Dateien liefern eine signierte URL (60 Sekunden, url_type=signed), SharePoint-Dateien den hinterlegten Freigabelink (url_type=sharepoint, ohne Ablauf im Feld expires_in). Ohne Ablageort: 409 file_not_available.",
      "params": [],
      "fields": [
        "file_id",
        "order_id",
        "file_name",
        "mime_type",
        "file_size",
        "url",
        "url_type",
        "expires_in",
        "expires_at",
        "content_sha256",
        "storage_path"
      ],
      "requiredScope": "files:read",
      "example": "curl -H 'X-API-Key: …' https://api.pt24.at/v1/order-files/UUID/download"
    },
    {
      "method": "GET",
      "path": "/v1/qr-codes",
      "description": "QR-Codes mit Kurz-Link, Ziel-Link, Design und Scan-Zähler. Der Kurz-Link (redirect_url) bleibt stabil, auch wenn der Ziel-Link später geändert wird.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid | null",
          "description": "Nur QR-Codes dieses Kunden; 'null' = ohne Kundenzuordnung."
        },
        {
          "name": "folder_id",
          "type": "uuid | null",
          "description": "Nur QR-Codes dieses Ordners; 'null' = ohne Ordner."
        },
        {
          "name": "is_active",
          "type": "boolean",
          "description": "Nur aktive bzw. nur inaktive Codes."
        },
        {
          "name": "short_code",
          "type": "string",
          "description": "Exakte Suche nach dem Kurzcode."
        },
        {
          "name": "q",
          "type": "string",
          "description": "Suche über Name, Beschreibung, Ziel-Link und Kurzcode."
        },
        {
          "name": "updated_since",
          "type": "ISO-8601",
          "description": "Delta-Sync."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Default 100."
        },
        {
          "name": "offset",
          "type": "integer",
          "description": "Paging-Versatz, siehe meta.next_offset."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "folder_id",
        "name",
        "description",
        "target_url",
        "short_code",
        "redirect_url",
        "image_url",
        "is_active",
        "scan_count",
        "last_scanned_at",
        "design",
        "created_at",
        "updated_at"
      ],
      "requiredScope": "qr_codes:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/qr-codes?customer_id=UUID&is_active=true'"
    },
    {
      "method": "GET",
      "path": "/v1/qr-codes/:id",
      "description": "Einzelner QR-Code inkl. Design und Statistik.",
      "params": [],
      "requiredScope": "qr_codes:read"
    },
    {
      "method": "POST",
      "path": "/v1/qr-codes",
      "dryRun": true,
      "idempotency": true,
      "description": "QR-Code anlegen. Kurzcode und Kurz-Link (https://qr.pt24.at/<code>) werden serverseitig vergeben. Unterstützt ?dry_run=true und Idempotency-Key.",
      "params": [
        {
          "name": "name",
          "type": "string (Pflicht)",
          "description": "Bezeichnung."
        },
        {
          "name": "target_url",
          "type": "url (Pflicht)",
          "description": "Ziel-Link (http oder https)."
        },
        {
          "name": "description",
          "type": "string",
          "description": "Beschreibung."
        },
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Kundenzuordnung."
        },
        {
          "name": "folder_id",
          "type": "uuid",
          "description": "Ordner (muss zum Kunden passen)."
        },
        {
          "name": "fg_color / bg_color",
          "type": "hex",
          "description": "Farben, Default #000000 / #FFFFFF."
        },
        {
          "name": "ec_level",
          "type": "L|M|Q|H",
          "description": "Fehlerkorrektur, Default M."
        },
        {
          "name": "size",
          "type": "64..2048",
          "description": "Kantenlänge in Pixel, Default 300."
        },
        {
          "name": "label_top / label_bottom",
          "type": "string",
          "description": "Beschriftung über/unter dem Code (Oberfläche)."
        },
        {
          "name": "style, eye_radius, eye_color_inner, eye_color_outer, show_arrows, logo_url, logo_size",
          "type": "diverse",
          "description": "Weitere Designfelder wie in der Oberfläche."
        },
        {
          "name": "is_active",
          "type": "boolean",
          "description": "Default true."
        }
      ],
      "requiredScope": "qr_codes:write",
      "example": "curl -X POST -H 'X-API-Key: …' -H 'Content-Type: application/json' -d '{\"name\":\"Plakat Herbst\",\"target_url\":\"https://pt24.at/aktion\",\"customer_id\":\"UUID\"}' https://api.pt24.at/v1/qr-codes"
    },
    {
      "method": "PATCH",
      "path": "/v1/qr-codes/:id",
      "dryRun": true,
      "description": "QR-Code ändern — inkl. Ziel-Link umbiegen (der Kurz-Link bleibt gleich), aktivieren/deaktivieren, Kunde/Ordner und Design. Unterstützt ?dry_run=true.",
      "params": [
        {
          "name": "target_url",
          "type": "url",
          "description": "Neuer Ziel-Link."
        },
        {
          "name": "is_active",
          "type": "boolean",
          "description": "Aktivieren oder deaktivieren."
        },
        {
          "name": "name, description, customer_id, folder_id, fg_color, bg_color, ec_level, size, style, label_top, label_bottom, logo_url, logo_size, eye_radius, eye_color_inner, eye_color_outer, show_arrows",
          "type": "diverse",
          "description": "Alle weiteren Felder wie bei der Anlage."
        }
      ],
      "requiredScope": "qr_codes:write"
    },
    {
      "method": "DELETE",
      "path": "/v1/qr-codes/:id",
      "dryRun": true,
      "description": "QR-Code samt Scan-Verlauf löschen. Der Kurz-Link funktioniert danach nicht mehr. Unterstützt ?dry_run=true.",
      "params": [],
      "requiredScope": "qr_codes:write"
    },
    {
      "method": "GET",
      "path": "/v1/qr-codes/:id/image",
      "description": "Fertige Grafik als PNG oder SVG. Enthält den Kurz-Link als Inhalt. Das Logo-Overlay der Oberfläche wird hier NICHT eingerechnet — die Datei ist der reine QR-Code.",
      "params": [
        {
          "name": "format",
          "type": "png | svg",
          "description": "Default png."
        },
        {
          "name": "size",
          "type": "64..2048",
          "description": "Kantenlänge in Pixel; Default = gespeicherte Größe."
        },
        {
          "name": "fg / bg",
          "type": "hex",
          "description": "Farben überschreiben."
        },
        {
          "name": "ec",
          "type": "L|M|Q|H",
          "description": "Fehlerkorrektur überschreiben."
        },
        {
          "name": "quiet_zone",
          "type": "0..16",
          "description": "Weißer Rand in Modulen, Default 4."
        }
      ],
      "requiredScope": "qr_codes:read",
      "example": "curl -H 'X-API-Key: …' 'https://api.pt24.at/v1/qr-codes/UUID/image?format=svg&size=1024' -o qr.svg"
    },
    {
      "method": "GET",
      "path": "/v1/qr-codes/:id/scans",
      "description": "Scan-Verlauf mit Zeitpunkt, Gerätekennung und Herkunft. meta enthält scan_count und last_scanned_at.",
      "params": [
        {
          "name": "since",
          "type": "ISO-8601",
          "description": "Nur Scans ab diesem Zeitpunkt."
        },
        {
          "name": "until",
          "type": "ISO-8601",
          "description": "Nur Scans bis zu diesem Zeitpunkt."
        },
        {
          "name": "limit",
          "type": "1..1000",
          "description": "Default 100."
        },
        {
          "name": "offset",
          "type": "integer",
          "description": "Paging-Versatz."
        }
      ],
      "fields": [
        "id",
        "qr_code_id",
        "scanned_at",
        "user_agent",
        "referer",
        "country"
      ],
      "requiredScope": "qr_codes:read"
    },
    {
      "method": "POST",
      "path": "/v1/qr-codes/:id/reset-stats",
      "dryRun": true,
      "description": "Scan-Verlauf löschen und Zähler auf 0 setzen. Unterstützt ?dry_run=true.",
      "params": [],
      "requiredScope": "qr_codes:write"
    },
    {
      "method": "GET",
      "path": "/v1/qr-code-folders",
      "description": "Ordner zur Ablage von QR-Codes (je Kunde, optional je Auftrag).",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid",
          "description": "Nur Ordner dieses Kunden."
        },
        {
          "name": "order_id",
          "type": "uuid",
          "description": "Nur Ordner dieses Auftrags."
        }
      ],
      "fields": [
        "id",
        "customer_id",
        "order_id",
        "name",
        "created_by",
        "created_at"
      ],
      "requiredScope": "qr_codes:read"
    },
    {
      "method": "POST",
      "path": "/v1/qr-code-folders",
      "dryRun": true,
      "description": "Ordner anlegen. Benötigt customer_id, name und einen handelnden Benutzer am API-Key. Unterstützt ?dry_run=true.",
      "params": [
        {
          "name": "customer_id",
          "type": "uuid (Pflicht)",
          "description": "Kunde."
        },
        {
          "name": "name",
          "type": "string (Pflicht)",
          "description": "Ordnername."
        },
        {
          "name": "order_id",
          "type": "uuid",
          "description": "Optionale Auftragszuordnung."
        }
      ],
      "requiredScope": "qr_codes:write"
    }
  ],
  "server_time": "2026-09-28T13:12:56.041Z"
}