openapi: 3.1.0
info:
  title: campaignKit Public API
  version: "1.0.0"
  description: >
    Einheitliche, versionierte API zwischen Kundenplattformen und der Meta
    Marketing API. Der mitgeschickte API-Key bestimmt den Meta-Account; der
    Meta-Token verlässt niemals den Server.

    Diese Spec wird roh unter `/docs/openapi.yaml` und `/docs/openapi.json` für
    Tooling (Postman, Codegen) ausgeliefert. Die menschenlesbare Docs-Seite und
    das `llms.txt`-Bündel stammen aus `lib/docs-data.ts` (separat gepflegt). Beide
    Quellen müssen synchron bleiben; der Drift-Wächter `lib/docs-sync.test.ts`
    vergleicht die Endpunkt-Mengen und schlägt bei Abweichung an.

    Kampagnen werden ausschließlich im Admin-Dashboard angelegt (kein POST über
    die API). Über die API werden Medien, Creatives, Ad-Sets und Ads erstellt;
    Optimierung, Placements, Geo, Budget-Obergrenze und DSA sind durch die
    Kampagnen-Konfiguration vorgegeben und können nicht überschrieben werden.
    Reporting, Webhooks und Audiences (Custom/Lookalike) sind verfügbar.
    Conversion-Events sendet die Kundenplattform selbst per Pixel/CAPI direkt an
    Meta, daher gibt es dafür keinen API-Endpunkt.
servers:
  - url: /api/v1
    description: Versioniert unter /api/v1

security:
  - bearerAuth: []

tags:
  - name: System
    description: Verbindung & Health.
  - name: Media
    description: Bild-Upload (liefert image_hash für Creatives).
  - name: Creatives
    description: Ad-Creatives (Bild + externe Landingpage).
  - name: AdSets
    description: Ad-Sets unter einer (im Dashboard angelegten) Kampagne.
  - name: Ads
    description: Ads (Ad-Set + Creative).
  - name: Templates
    description: Zugewiesene Vorlagen + Parameter-Schema (Vorlage für KI-Inhalte).
  - name: Targeting
    description: Lookup-Helfer für Geo- und Interessen-Keys.
  - name: Status
    description: Live-Status einer Ad (effective_status + Ablehnungsgrund).
  - name: Webhooks
    description: Status-Webhooks (Registrierung; Push bei ad.status_changed).
  - name: Reporting
    description: Insights & Reporting (Spend, Conversions, ROAS, Breakdowns).
  - name: Audiences
    description: Custom-/Lookalike-Audiences (Kontakte werden serverseitig gehasht, keine PII at rest).
  - name: Plan
    description: Vertragszustand, voraussichtliche Gebühr, Deaktivieren und Rechnungs-Portal (rückwirkende Stufen-Abrechnung).

paths:
  /health:
    get:
      operationId: getHealth
      tags: [System]
      summary: Authentifizierter Health-Check
      description: >
        Prüft, dass der API-Key gültig ist und die API erreichbar. Erfordert
        mindestens den Scope `reporting`.
      responses:
        "200":
          description: API erreichbar.
          content:
            application/json:
              schema:
                type: object
                required: [data, request_id]
                properties:
                  data:
                    type: object
                    required: [status, api_version]
                    properties:
                      status:
                        type: string
                        example: ok
                      api_version:
                        type: string
                        example: v1
                  request_id:
                    type: string
                    format: uuid
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /media/images:
    post:
      operationId: uploadMediaImage
      tags: [Media]
      summary: Bild von URL hochladen
      description: >
        Übergibt ein Bild per öffentlich erreichbarer https-URL. Unser Server
        lädt die Datei und reicht die Bytes UNVERÄNDERT (keine Kompression) an
        Meta weiter; zurück kommt der `image_hash` für Creatives. Synchron. Die
        URL wird validiert (nur https, keine internen Ziele, keine Redirects).
        Mit unserem Supabase: signierte URL via createSignedUrl(path, 3600).
        Ohne Supabase: beliebige öffentlich erreichbare https-URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Direkte https-URL zur fertigen Bilddatei (JPG/PNG/WebP, ≤ 30 MB), von unserem Backend abrufbar, ohne Weiterleitung.
      responses:
        "201":
          description: Bild an Meta übergeben.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      image_hash: { type: string }
                      url: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
    get:
      operationId: listMediaImages
      tags: [Media]
      summary: Bilder aus Meta auflisten
      description: >
        Listet die Ad-Images des Werbekontos direkt aus Meta. Da Konto und Kunde
        1:1 zugeordnet sind, ist das die Bild-Mediathek des Kunden. Optional per
        `hash` auf ein einzelnes Bild gefiltert. Scope `reporting` genügt.
      parameters:
        - in: query
          name: hash
          required: false
          schema: { type: string }
          description: Auf einen einzelnen image_hash filtern.
      responses:
        "200":
          description: Liste der Ad-Images aus Meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        image_hash: { type: string }
                        url: { type: string, nullable: true }
                        width: { type: integer, nullable: true }
                        height: { type: integer, nullable: true }
                        name: { type: string, nullable: true }
                        created_time: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /media/videos:
    post:
      operationId: uploadMediaVideo
      tags: [Media]
      summary: Video von URL hochladen
      description: >
        Übergibt ein Video per öffentlich erreichbarer https-URL. Anders als beim
        Bild lädt META die Datei selbst (file_url) — die Bytes laufen nie durch
        uns, daher sind große bis 4K-Videos möglich. Zurück kommt die `video_id`
        und ein Status (meist `PROCESSING`); asynchron, daher über
        GET /media/videos/{id} pollen, bis `READY`. Mit unserem Supabase:
        signierte URL via createSignedUrl(path, 3600) (Gültigkeit ≥ 1 h, da Meta
        sie abruft). Ohne Supabase: beliebige öffentlich erreichbare https-URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Direkte https-URL zum fertigen Video (MP4/MOV), von Metas Servern abrufbar.
      responses:
        "201":
          description: Video an Meta übergeben, Verarbeitung läuft asynchron.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      video_id: { type: string }
                      status:
                        type: string
                        enum: [PROCESSING, READY, FAILED]
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
    get:
      operationId: listMediaVideos
      tags: [Media]
      summary: Videos aus Meta auflisten
      description: >
        Listet die Ad-Videos des Werbekontos direkt aus Meta (video_id, Titel,
        Status, Länge, Erstellzeit). Da Konto und Kunde 1:1 zugeordnet sind, ist
        das die Video-Mediathek des Kunden. Scope `reporting` genügt.
      responses:
        "200":
          description: Liste der Ad-Videos aus Meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        video_id: { type: string }
                        title: { type: string, nullable: true }
                        status:
                          type: string
                          enum: [PROCESSING, READY, FAILED]
                        length: { type: integer, nullable: true }
                        created_time: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /media/videos/{id}:
    get:
      operationId: getMediaVideoStatus
      tags: [Media]
      summary: Video-Verarbeitungsstatus pollen
      description: >
        Liefert den aktuellen Verarbeitungsstatus eines Videos: `PROCESSING`,
        `READY` oder `FAILED`. `READY` = im Creative nutzbar. Scope `reporting`
        genügt.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
          description: Interne Video-Asset-ID aus POST /media/videos.
      responses:
        "200":
          description: Aktueller Verarbeitungsstatus.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      video_id: { type: string }
                      status:
                        type: string
                        enum: [PROCESSING, READY, FAILED]
                      processed_at: { type: string, format: date-time, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: Zugewiesene Vorlagen auflisten
      description: >
        Listet die dem Kunden zugewiesenen, aktiven Vorlagen (UUID, Name,
        Beschreibung, `type` = `image`/`video`). Je Vorlage zusätzlich
        `preview_urls` (je Format die URL des Vorschau-Bildes) und `has_params`
        (ob für diese Vorlage Vorschau-Werte hinterlegt sind). Das Auflisten
        rendert nicht; die Bilder werden erst beim Abruf der jeweiligen preview_url
        lazy gerendert und danach gecacht. Die Parameter pflegt der Betreiber je
        Kunde im Admin-Dashboard. Das Parameter-Schema einer Vorlage liefert
        GET /templates/{id}. Scope `reporting` genügt.
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { in: query, name: offset, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Liste der zugewiesenen Vorlagen.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/PublicTemplateSummary"
                        - type: object
                          properties:
                            has_params:
                              type: boolean
                              description: True, wenn für diese Vorlage Vorschau-Werte hinterlegt sind.
                            preview_urls:
                              $ref: "#/components/schemas/TemplatePreviewUrls"
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /templates/{id}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Parameter-Schema einer Vorlage (für KI)
      description: >
        Liefert das einheitliche Erklärungs-Schema einer zugewiesenen, aktiven
        Vorlage: je Parameter Name, Typ, Pflicht/optional, Bezeichnung,
        Beschreibung (Hinweis für die Inhaltserstellung), Min-/Max-Zeichen und ein
        Beispiel. Nur zugewiesene Vorlagen — sonst 404. Scope `reporting`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Vorlagen-Schema.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PublicTemplateDetail" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates/{id}/preview:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getTemplatePreviewParams
      tags: [Templates]
      summary: Hinterlegte Vorschau-Parameter lesen
      description: >
        Liefert die für diese Vorlage hinterlegten Parameter-Werte (vom Betreiber
        je Kunde im Admin-Dashboard gepflegt; leeres Objekt, wenn noch nichts
        gesetzt) plus die preview_urls je Format. Scope `reporting`.
      responses:
        "200":
          description: Gespeicherte Werte + Vorschau-URLs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/TemplatePreviewParams" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates/{id}/preview.png:
    get:
      operationId: getTemplatePreviewImage
      tags: [Templates]
      summary: Vorschau-Bild (öffentlich, direkt einbettbar)
      # Öffentlich: kein API-Key. Autorisierung über die signierten Query-Params
      # c + k (in den preview_urls enthalten). Daher Security hier überschrieben.
      security: []
      description: >
        Öffentlicher Bild-Endpunkt, direkt im Browser einbettbar (img-Tag) ohne
        API-Key. Autorisiert über die signierten Query-Parameter `c` (Kunde) und
        `k` (Lese-Token), die bereits in den preview_urls aus GET /templates
        enthalten sind — die preview_url wird unverändert verwendet, nicht selbst
        gebaut. Liefert das Bild gefüllt mit den vom Betreiber hinterlegten
        Parametern (sonst den Beispiel-Werten der Vorlage) als 302-Redirect auf
        eine öffentliche, unratbare Bild-URL (Bytes via Storage-CDN). Das Bild
        wird vorab gerendert und content-addressiert gecacht (spätestens beim
        ersten Abruf); nach einer Parameter- oder Vorlagen-Änderung beim nächsten
        Abruf einmalig neu erzeugt. Der Token ist stabil und läuft nicht ab.
        Ungültiger/fehlender Token oder unbekannte/entzogene Vorlage → 404.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: query
          name: c
          required: true
          description: Kunden-Kennung (Teil der Signatur, in der preview_url enthalten).
          schema: { type: string, format: uuid }
        - in: query
          name: k
          required: true
          description: Signierter Lese-Token (in der preview_url enthalten).
          schema: { type: string }
        - in: query
          name: ratio
          required: false
          schema: { type: string, enum: ["1:1", "4:5", "9:16"], default: "1:1" }
      responses:
        "302":
          description: Redirect auf die Bild-URL (Header `Location`).
          headers:
            Location:
              schema: { type: string, format: uri }
        "404": { $ref: "#/components/responses/NotFound" }

  /templates/render:
    post:
      operationId: renderTemplate
      tags: [Templates]
      summary: Vorlage rendern (Creative-Entwurf, nur Visual)
      description: >
        Rendert eine aktive Vorlage mit den übergebenen `params` in die drei
        Formate 1:1, 4:5 und 9:16 und legt einen Creative-ENTWURF an. Hier
        entsteht nur das **Visual** — die Anzeigen-Copy (Primärer Text, Überschrift,
        Beschreibung, Call-to-Action, Ziel-Link, angezeigter Link) gehört zur
        Anzeige und wird in POST /ads gesetzt; dort wird aus diesem Entwurf das
        finale Creative gebaut. Die zurückgegebene `id` ist die `creative_id` für
        POST /ads — die Antwort enthält noch KEINE `meta_creative_id`. Enthält die
        Vorlage einen Video-Layer, läuft das Rendern asynchron (Antwort 202,
        Status PROCESSING). Param-gebundene Bild-/Logo-URLs werden vor dem Abruf
        gegen interne Ziele geprüft (SSRF-Schutz) und große externe Bilder vorab
        verkleinert. Scope `full`, idempotent (Header `Idempotency-Key`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template]
              properties:
                template:
                  type: string
                  format: uuid
                  description: UUID der Vorlage (nur aktive Vorlagen werden akzeptiert).
                params:
                  type: object
                  additionalProperties: { type: string }
                  description: >-
                    Key/Value je Vorlagen-Parameter (siehe GET /templates/{id}).
                    Bild-URLs akzeptieren JPG, PNG oder WebP; ein nicht
                    erreichbares oder ungültiges Bild wird mit 422 abgelehnt
                    (nicht still weggelassen).
                name:
                  type: string
                  description: Optionaler interner Name des erzeugten Creatives.
                  example: "Sommer-Sale · 1080"
      responses:
        "200":
          description: Vorschau erzeugt (ohne `link`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      template: { type: string, format: uuid }
                      type: { type: string, enum: [IMAGE, VIDEO] }
                      status: { type: string, example: PREVIEW }
                      formats:
                        type: object
                        additionalProperties: { type: string, format: uri }
                        description: Je Format (1:1, 4:5, 9:16) die Bild-URL (nur IMAGE).
                      overlays:
                        type: object
                        additionalProperties: { type: string, format: uri }
                        description: Je Format die transparente Overlay-PNG-URL (nur VIDEO).
                  request_id: { type: string, format: uuid }
        "201":
          description: Bild-Creative finalisiert (mit `link`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      meta_creative_id: { type: string }
                      template: { type: string, format: uuid }
                      type: { type: string, example: IMAGE }
                      formats:
                        type: object
                        additionalProperties: { type: string, format: uri }
                  request_id: { type: string, format: uuid }
        "202":
          description: Video-Render-Job angelegt (mit `link`); Creative-Status PROCESSING.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      type: { type: string, example: VIDEO }
                      status: { type: string, example: PROCESSING }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /templates/render/{id}:
    get:
      operationId: getTemplateRenderStatus
      tags: [Templates]
      summary: Status eines (Video-)Render-Auftrags
      description: >
        Liefert den Status eines asynchronen Video-Renders: PROCESSING
        (Compositing/Upload läuft), READY (`meta_creative_id` vorhanden, in
        POST /ads nutzbar) oder FAILED (`error` gesetzt). `{id}` ist die
        Creative-ID aus dem 202-Response von POST /templates/render. Reiner
        DB-Read. Scope `reporting`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Render-Status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, enum: [PROCESSING, READY, FAILED] }
                      meta_creative_id: { type: string, nullable: true }
                      video_id: { type: string, nullable: true }
                      output_url: { type: string, format: uri, nullable: true }
                      error: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /creatives:
    get:
      operationId: listCreatives
      tags: [Creatives]
      summary: Creatives lesen
      description: >
        Listet die Creatives des Kunden aus dem DB-Spiegel. Scope `reporting`.
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [ACTIVE, PAUSED, ARCHIVED] } }
        - { in: query, name: limit, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { in: query, name: offset, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Liste der Creatives.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        type: { type: string }
                        status: { type: string }
                        link: { type: string }
                        headline: { type: string }
                        meta_creative_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createCreative
      tags: [Creatives]
      summary: Medium als Creative-Entwurf registrieren
      description: >
        Registriert ein hochgeladenes Medium (image_hash aus /media/images ODER
        video_id aus /media/videos — genau eines) als Creative-ENTWURF. Es wird
        KEIN Meta-Creative gebaut und KEINE Copy gesetzt: Texte, CTA, Ziel-Link
        und angezeigter Link gehören zur Anzeige und werden in POST /ads
        übergeben — dort entsteht aus diesem Entwurf (creative_id) das finale
        Creative. Gleicher Ablauf wie beim Vorlagen-Render.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                image_hash:
                  type: string
                  description: Bild-Hash aus POST /media/images. Genau eines von image_hash oder video_id erforderlich.
                  example: a1b2c3d4e5f67890a1b2c3d4e5f67890
                video_id:
                  type: string
                  description: Video-ID aus POST /media/videos. Genau eines von image_hash oder video_id erforderlich. Muss spätestens bei POST /ads READY sein.
                  example: "239847562018"
                thumbnail_hash:
                  type: string
                  description: Nur bei video_id. Optionaler image_hash als Video-Thumbnail; ohne Angabe nutzt POST /ads das automatische Meta-Thumbnail.
                name:
                  type: string
                  description: Optionaler interner Name des Entwurfs.
                  example: "Headliner Visual"
      responses:
        "201":
          description: Creative-Entwurf angelegt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      type: { type: string }
                      status: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /creatives/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Interne Creative-ID (aus POST/GET).
    patch:
      operationId: updateCreative
      tags: [Creatives]
      summary: Creative umbenennen
      description: >
        Benennt ein Creative um. Inhalt (Bild, Text, Link) ist bei Meta
        unveränderlich — nur `name` lässt sich ändern.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 200, example: "Headliner Visual v2" }
      responses:
        "200":
          description: Creative aktualisiert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      type: { type: string }
                      status: { type: string }
                      meta_creative_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: archiveCreative
      tags: [Creatives]
      summary: Creative archivieren
      description: >
        Archiviert ein Creative (status=ARCHIVED) — nur in campaignKit; das
        Meta-Creative bleibt bestehen (wird ggf. von Ads weiterverwendet).
      responses:
        "200":
          description: Creative archiviert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, example: ARCHIVED }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /adsets:
    get:
      operationId: listAdSets
      tags: [AdSets]
      summary: Ad-Sets lesen
      description: >
        Listet die Ad-Sets des Kunden aus dem DB-Spiegel, optional nach Ziel
        (`target`) und Status gefiltert. Die Antwort enthält `target` statt einer
        Kampagnen-ID. Scope `reporting`.
      parameters:
        - { in: query, name: target, schema: { type: string } }
        - { in: query, name: status, schema: { type: string, enum: [ACTIVE, PAUSED, ARCHIVED] } }
        - { in: query, name: limit, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { in: query, name: offset, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Liste der Ad-Sets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        target: { type: string }
                        name: { type: string }
                        daily_budget_cents: { type: integer }
                        lifetime_budget_cents: { type: integer }
                        start_time: { type: string, format: date-time }
                        end_time: { type: string, format: date-time }
                        status: { type: string }
                        effective_status: { type: string }
                        meta_adset_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createAdSet
      tags: [AdSets]
      summary: Ad-Set erstellen
      description: >
        Erstellt ein Ad-Set unter dem per `target` referenzierten Ziel.
        `optimization_goal`, `billing_event`, `targeting.publisher_platforms`,
        `targeting.facebook_positions`, `targeting.instagram_positions`,
        `targeting.device_platforms`, `targeting.geo_locations` sowie
        `promoted_object`, `name`, `status` und `bid_strategy` sind durch das
        Ziel vorgegeben — werden sie mitgeschickt, antwortet die API mit 422.
        Der Kunde liefert nur Budget/Schedule und optional Alter/Geschlecht/
        Interessen. Budget ist **Pflicht**: genau eines von `daily_budget_cents`
        oder `lifetime_budget_cents` muss gesetzt sein — das Ziel führt kein
        Budget, daher liegt es verbindlich auf dem Ad-Set. `lifetime_budget_cents`
        erfordert zusätzlich `end_time`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              oneOf:
                - required: [daily_budget_cents]
                - required: [lifetime_budget_cents]
              properties:
                target:
                  type: string
                  description: >
                    Ziel-Schlüssel des von uns eingerichteten Ziels. Bestimmt
                    Optimierung, Platzierung, Geo, Pixel und DSA. Erhältst du von uns.
                  example: sommerfest-2026
                daily_budget_cents:
                  type: integer
                  minimum: 1
                  description: >
                    Tagesbudget in Cent (5000 = 50,00 €). Pflicht, sofern kein
                    lifetime_budget_cents gesetzt ist (genau eines von beiden).
                  example: 5000
                lifetime_budget_cents:
                  type: integer
                  minimum: 1
                  description: >
                    Laufzeitbudget in Cent (50000 = 500,00 €). Pflicht, sofern kein
                    daily_budget_cents gesetzt ist. Erfordert end_time.
                  example: 50000
                start_time: { type: string, format: date-time, example: "2026-06-01T10:00:00+02:00" }
                end_time: { type: string, format: date-time, example: "2026-06-30T23:59:59+02:00" }
                targeting:
                  type: object
                  properties:
                    age_min: { type: integer, minimum: 13, maximum: 65, example: 25 }
                    age_max: { type: integer, minimum: 13, maximum: 65, example: 45 }
                    genders:
                      type: array
                      items: { type: integer, enum: [1, 2] }
                      example: [1, 2]
                    flexible_spec:
                      type: array
                      items: { type: object, additionalProperties: true }
                      example:
                        - interests:
                            - { id: "6003139266461", name: "Live music" }
      responses:
        "201":
          description: Ad-Set erstellt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      meta_adset_id: { type: string }
                      name: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /adsets/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Interne Ad-Set-ID (aus POST/GET).
    patch:
      operationId: updateAdSet
      tags: [AdSets]
      summary: Ad-Set ändern
      description: >
        Aktualisiert Budget, Laufzeit oder Status eines Ad-Sets (partielles
        Update). Gesperrte Felder (optimization_goal, billing_event, targeting
        u. a.) führen zu 422. Erstes Aktivieren (`status: ACTIVE`) zählt gegen das
        Monatslimit des Plans und liefert bei Erschöpfung 402 `quota_exceeded`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                daily_budget_cents: { type: integer, minimum: 1, example: 5000 }
                lifetime_budget_cents: { type: integer, minimum: 1, example: 50000 }
                start_time: { type: string, format: date-time }
                end_time: { type: string, format: date-time }
                status: { type: string, enum: [ACTIVE, PAUSED] }
      responses:
        "200":
          description: Ad-Set aktualisiert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      daily_budget_cents: { type: integer }
                      lifetime_budget_cents: { type: integer }
                      start_time: { type: string, format: date-time }
                      end_time: { type: string, format: date-time }
                      status: { type: string }
                      meta_adset_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/QuotaExceeded" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: archiveAdSet
      tags: [AdSets]
      summary: Ad-Set archivieren
      description: Archiviert ein Ad-Set (status=ARCHIVED) bei uns und bei Meta.
      responses:
        "200":
          description: Ad-Set archiviert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, example: ARCHIVED }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /ads:
    get:
      operationId: listAds
      tags: [Ads]
      summary: Ads lesen
      description: >
        Listet die Ads des Kunden inkl. gecachtem `effective_status` aus dem
        DB-Spiegel. Für den frischen Live-Status siehe `GET /status/{ad_id}`.
        Scope `reporting`.
      parameters:
        - { in: query, name: adset_id, schema: { type: string, format: uuid } }
        - { in: query, name: status, schema: { type: string, enum: [ACTIVE, PAUSED, ARCHIVED] } }
        - { in: query, name: limit, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { in: query, name: offset, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Liste der Ads.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        ad_set_id: { type: string, format: uuid }
                        creative_id: { type: string, format: uuid }
                        name: { type: string }
                        status: { type: string }
                        effective_status: { type: string }
                        disapproval_reason: { type: string, nullable: true }
                        meta_ad_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createAd
      tags: [Ads]
      summary: Anzeige einstellen + erstellen
      description: >
        Stellt die Anzeige ein (wie im Werbeanzeigenmanager) und legt sie an.
        HIER gehört die Anzeigen-Copy hin (nicht ins Template): Primärer Text,
        Überschrift, Beschreibung (je genau ein Wert), Call-to-Action, Ziel-Link,
        optionaler angezeigter Link sowie Ad-Name und -Status. Aus dem Render-Entwurf (creative_id aus /v1/templates/render)
        wird das finale Multi-Ratio-Creative gebaut und die Ad erstellt. Ist
        creative_id bereits finalisiert (z. B. aus /v1/creatives), werden die
        Copy-Felder ignoriert.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ad_set_id, creative_id]
              properties:
                ad_set_id: { type: string, format: uuid, example: a51f9c33-7e2b-4d18-8a0e-6b9c1d4f2e77 }
                creative_id: { type: string, format: uuid, example: 1f0c7a44-2d9e-4c61-b3a8-5e7f0c2a1b9d }
                link:
                  type: string
                  format: uri
                  description: >
                    Echte Ziel-URL. PFLICHT, wenn creative_id ein Render-Entwurf
                    ist. Nur die saubere URL angeben — UTMs hängt campaignKit
                    automatisch an; KEINE eigenen Tracking-Parameter.
                  example: "https://acme-events.de/tickets"
                display_link:
                  type: string
                  maxLength: 255
                  description: >
                    Optionaler angezeigter Link (Display-URL). Kurze, lesbare
                    Marken-URL ohne https:// und ohne Query-Parameter.
                  example: "acme-events.de/tickets"
                call_to_action:
                  type: string
                  default: LEARN_MORE
                  enum: [SIGN_UP, SUBSCRIBE, SEE_MORE, DOWNLOAD, APPLY_NOW, BOOK_NOW, BUY_TICKETS, CONTACT_US, GET_OFFER, GET_QUOTE, GET_SHOWTIMES, WATCH_MORE, LEARN_MORE, LISTEN_NOW, ORDER_NOW]
                  example: BUY_TICKETS
                message:
                  type: string
                  maxLength: 2200
                  description: >
                    In Meta "Primärer Text": der wichtigste Text der Anzeige,
                    erscheint in den meisten Platzierungen (z. B. Facebook-Feed)
                    über oder unter Bild/Video. Vermittelt die Hauptbotschaft und
                    macht neugierig. Maximal 125 Zeichen empfohlen; idealerweise
                    nur 1 bis 3 Zeilen, damit der Text ohne "Mehr anzeigen"
                    vollständig lesbar ist (die API akzeptiert technisch bis 2.200).
                    Kein Call-to-Action. Pro Anzeige genau eine Variante (mehrere
                    Text-Varianten sind mit den Multi-Format-Creatives nicht
                    kombinierbar).
                  example: "Triff Gründer und Macher beim Networking-Event in Berlin am 18. Juli ab 19 Uhr."
                headline:
                  type: string
                  maxLength: 255
                  description: >
                    In Meta "Überschrift": erscheint meist fett neben dem
                    Call-to-Action-Button und hebt das wichtigste Verkaufsargument
                    bzw. den Nutzen kurz hervor. Maximal 40 Zeichen empfohlen, dann
                    bleibt sie auch auf kleinen Bildschirmen einzeilig (technisch
                    bis 255). Kein Call-to-Action.
                  example: "Founders Night Berlin"
                description:
                  type: string
                  maxLength: 255
                  description: >
                    In Meta "Beschreibung": optionale Zusatzinfo unter der
                    Überschrift, nicht in allen Platzierungen sichtbar. Für nicht
                    essenzielle Details (z. B. Lieferinfo, kurzes Testimonial).
                    Maximal 25 Zeichen empfohlen, da der Text oft abgeschnitten
                    wird (technisch bis 255). Kein Call-to-Action.
                  example: "Fr. 18. Juli, 19 Uhr"
                name:
                  type: string
                  maxLength: 200
                  description: Optionaler Anzeigenname (sonst Naming Convention).
                status:
                  type: string
                  enum: [PAUSED, ACTIVE]
                  default: PAUSED
                  description: PAUSED (Default) oder ACTIVE (nach Review ausspielen).
      responses:
        "201":
          description: Ad erstellt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      meta_ad_id: { type: string }
                      meta_creative_id: { type: string }
                      name: { type: string }
                      status: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /ads/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Interne Ad-ID (aus POST/GET).
    patch:
      operationId: updateAd
      tags: [Ads]
      summary: Ad ändern
      description: Setzt den Status einer Ad (z. B. nach erfolgreichem Review aktivieren).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [ACTIVE, PAUSED] }
      responses:
        "200":
          description: Ad aktualisiert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      status: { type: string }
                      effective_status: { type: string }
                      meta_ad_id: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: archiveAd
      tags: [Ads]
      summary: Ad archivieren
      description: Archiviert eine Ad (status=ARCHIVED) bei uns und bei Meta.
      responses:
        "200":
          description: Ad archiviert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, example: ARCHIVED }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /targeting/geo:
    get:
      operationId: targetingGeo
      tags: [Targeting]
      summary: Stadt-Lookup (DACH City-Keys)
      description: >
        Sucht Städte über Metas Search-API und liefert die `key`s für
        `geo_locations.cities` im Ad-Set-Targeting. Scope `reporting`.
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string, example: Berlin }
        - in: query
          name: country_code
          required: false
          schema: { type: string, enum: [DE, AT, CH] }
        - in: query
          name: limit
          required: false
          schema: { type: integer, default: 25, maximum: 50 }
      responses:
        "200":
          description: Treffer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        key: { type: string, example: "2972535" }
                        name: { type: string, example: Berlin }
                        type: { type: string, example: city }
                        country_code: { type: string, nullable: true, example: DE }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /targeting/interests:
    get:
      operationId: targetingInterests
      tags: [Targeting]
      summary: Interessen-Lookup
      description: >
        Sucht Interessen-Targeting-Optionen und liefert deren `id`s für
        `flexible_spec` im Ad-Set-Targeting. Scope `reporting`.
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string, example: Live-Musik }
        - in: query
          name: limit
          required: false
          schema: { type: integer, default: 25, maximum: 50 }
      responses:
        "200":
          description: Treffer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: "6003139266461" }
                        name: { type: string, example: Live music }
                        audience_size: { type: integer, nullable: true, example: 248000000 }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /status/{ad_id}:
    get:
      operationId: getAdStatus
      tags: [Status]
      summary: Live effective_status einer Ad
      description: >
        Holt effective_status + Ablehnungsgrund live aus der Meta-API,
        aktualisiert den serverseitigen Cache und liefert den frischen Wert.
        `ad_id` ist die interne UUID. Scope `reporting`.
      parameters:
        - in: path
          name: ad_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Aktueller Status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ad_id: { type: string, format: uuid }
                      effective_status:
                        type: string
                        enum: [ACTIVE, PAUSED, PENDING_REVIEW, DISAPPROVED, ARCHIVED]
                      disapproval_reason: { type: string, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /insights:
    get:
      operationId: getInsights
      tags: [Reporting]
      summary: Performance-Insights (tägliche Snapshots)
      description: >
        Liefert normalisierte Performance-Metriken (Spend, Impressionen, Reichweite,
        Klicks, CTR, CPC, CPM, Conversions, Conversion-Wert, ROAS, CPA) je Entity auf
        Ad-Set- oder Ad-Ebene und Zeitspanne. Die Daten stammen aus täglich gesyncten
        Snapshots (Meta ist Source of Truth); Conversions werden über ein 7-Tage-Fenster
        nachträglich aktualisiert. Geldwerte in Major-Units der Konto-Währung. Die
        Kampagnen-Ebene ist nicht verfügbar. level=ad-Antworten enthalten zusätzlich
        adset_id. Scope `reporting`.
      parameters:
        - in: query
          name: level
          required: true
          schema: { type: string, enum: [adset, ad] }
        - in: query
          name: entity_id
          required: false
          description: >
            Interne UUID eines Ad-Sets (level=adset) oder einer Ad (level=ad).
            Ohne Angabe werden alle Entities der Ebene zurückgegeben.
          schema: { type: string, format: uuid }
        - in: query
          name: adset_id
          required: false
          description: >
            Nur mit level=ad: liefert die Metriken aller Ads dieses Ad-Sets — so
            erreichst du mit der Ad-Set-ID die enthaltenen Ads, ohne deren IDs zu kennen.
          schema: { type: string, format: uuid }
        - in: query
          name: date_range
          required: false
          description: >
            Preset-String (today, yesterday, last_3d, last_7d, last_14d, last_28d,
            last_30d, last_90d, this_month, last_month, this_week_mon_today,
            last_week_mon_sun, maximum) oder JSON-Objekt
            {"since":"YYYY-MM-DD","until":"YYYY-MM-DD"}. Default: last_30d.
          schema: { type: string, example: last_30d }
        - in: query
          name: fields
          required: false
          description: Kommagetrennte Auswahl der Kern-Metriken. Default ist das volle Set.
          schema: { type: string, example: "spend,impressions,clicks,ctr,cpc,roas" }
        - in: query
          name: breakdowns
          required: false
          description: >
            Aufschlüsselung nach einer gesyncten Dimension: age, gender, country,
            publisher_platform, platform_position, impression_device, device_platform
            oder die Kombination "age,gender". Muss exakt einem gesyncten Set entsprechen.
          schema: { type: string, example: "age,gender" }
      responses:
        "200":
          description: Normalisierte Metriken je Entity (bzw. je Entity und Breakdown-Wert).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid, description: Interne UUID der Entity. }
                        adset_id: { type: string, format: uuid, nullable: true, description: "Nur bei level=ad: Eltern-Ad-Set (zum Gruppieren)." }
                        meta_id: { type: string, nullable: true, description: Meta-ID der Entity. }
                        breakdown:
                          type: object
                          nullable: true
                          additionalProperties: { type: string }
                          example: { age: "25-34", gender: female }
                        spend: { type: number, example: 1928.00 }
                        impressions: { type: integer, example: 187400 }
                        reach:
                          type: integer
                          example: 92100
                          description: >
                            Erreichte Personen (Unique-Metrik). NICHT additiv über Tage: bei
                            einem mehrtägigen Zeitraum die höchste Einzeltags-Reichweite
                            (belastbare Untergrenze), bei einem einzelnen Tag exakt.
                        clicks: { type: integer, example: 5082 }
                        ctr: { type: number, nullable: true, example: 2.71 }
                        cpc: { type: number, nullable: true, example: 0.38 }
                        cpm: { type: number, nullable: true, example: 10.29 }
                        conversions: { type: integer, example: 246 }
                        conversion_value: { type: number, example: 9832.80 }
                        roas: { type: number, nullable: true, example: 5.1 }
                        cpa: { type: number, nullable: true, example: 7.84 }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /audiences:
    get:
      operationId: listAudiences
      tags: [Audiences]
      summary: Audiences lesen
      description: >
        Listet die Audiences des Kunden aus dem DB-Spiegel (Status/Größe aus dem
        letzten Sync, siehe last_synced_at). Scope `reporting`.
      parameters:
        - { in: query, name: subtype, schema: { type: string, enum: [CUSTOM, LOOKALIKE] } }
        - { in: query, name: status, schema: { type: string, enum: [active, deleted] } }
        - { in: query, name: limit, schema: { type: integer, default: 25, minimum: 1, maximum: 100 } }
        - { in: query, name: offset, schema: { type: integer, default: 0, minimum: 0 } }
      responses:
        "200":
          description: Liste der Audiences.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Audience" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /audiences/custom:
    post:
      operationId: createCustomAudience
      tags: [Audiences]
      summary: Custom Audience aus CRM-Kontakten
      description: >
        Erstellt eine Custom Audience aus rohen CRM-Kontakten. Kontakte werden
        serverseitig normalisiert und gehasht (SHA-256) und nicht gespeichert.
        Optionaler erster Batch (≤ 10.000) direkt beim Anlegen; weitere über
        POST /audiences/{id}/contacts.
        Custom Audiences setzen voraus, dass die Custom-Audience-
        Nutzungsbedingungen im Werbekonto akzeptiert sind UND das Werbekonto
        dafür freigeschaltet ist (sehr neue Konten oder Konten ohne ausgelieferte
        Anzeigen können noch geblockt sein). In beiden Fällen liefert Meta
        denselben Fehler; die Antwort ist 403 mit
        details.reason='custom_audience_unavailable'. Das ist KEIN
        API-Konfigurationsfehler: Nutzungsbedingungen akzeptieren und/oder zuerst
        eine Anzeige schalten und das Konto reifen lassen, dann erneut versuchen.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 255, example: "Käufer 2026" }
                description: { type: string, maxLength: 1000 }
                customer_file_source:
                  type: string
                  enum: [USER_PROVIDED_ONLY, PARTNER_PROVIDED_ONLY, BOTH_USER_AND_PARTNER_PROVIDED]
                  default: USER_PROVIDED_ONLY
                contacts:
                  type: array
                  maxItems: 10000
                  items: { $ref: "#/components/schemas/AudienceContact" }
      responses:
        "201":
          description: Custom Audience erstellt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      meta_audience_id: { type: string }
                      subtype: { type: string, example: CUSTOM }
                      contacts_received: { type: integer }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /audiences/lookalike:
    post:
      operationId: createLookalikeAudience
      tags: [Audiences]
      summary: Lookalike Audience erstellen
      description: >
        Erzeugt eine Lookalike-Audience aus einer eigenen Custom Audience
        (per interner UUID) für ein Land mit Ähnlichkeitsgrad (empfohlen 1–10 %).
        Kann wie Custom Audiences mit 403
        (details.reason='custom_audience_unavailable') ablehnen, wenn das
        Werbekonto noch nicht für Custom Audiences freigeschaltet ist oder die
        Nutzungsbedingungen fehlen. Das ist KEIN API-Konfigurationsfehler.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, source_audience_id, country, ratio]
              properties:
                name: { type: string, maxLength: 255, example: "LAL Käufer 2026 DE 3%" }
                description: { type: string, maxLength: 1000 }
                source_audience_id: { type: string, format: uuid }
                country: { type: string, minLength: 2, maxLength: 2, example: DE }
                ratio: { type: number, minimum: 0.01, maximum: 0.2, example: 0.03 }
      responses:
        "201":
          description: Lookalike Audience erstellt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      meta_audience_id: { type: string }
                      subtype: { type: string, example: LOOKALIKE }
                      source_audience_id: { type: string, format: uuid }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /audiences/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Interne Audience-ID.
    get:
      operationId: getAudience
      tags: [Audiences]
      summary: Audience-Detail inkl. Live-Status
      description: >
        Liefert eine Audience inkl. frischem Status von Meta (operation_status,
        delivery_status, approximate_count) und `ready` (true = in Ad-Sets
        nutzbar). Scope `reporting`.
      responses:
        "200":
          description: Audience-Detail.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/AudienceDetail" }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateAudience
      tags: [Audiences]
      summary: Audience ändern
      description: Ändert Name und/oder Beschreibung einer Audience.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string, maxLength: 1000 }
      responses:
        "200":
          description: Audience aktualisiert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      name: { type: string }
                      description: { type: string, nullable: true }
                      subtype: { type: string }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteAudience
      tags: [Audiences]
      summary: Audience löschen
      description: Löscht eine Audience bei Meta und markiert den Spiegel als deleted.
      responses:
        "200":
          description: Audience gelöscht.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string, example: deleted }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /audiences/{id}/contacts:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
        description: Interne Audience-ID (nur CUSTOM).
    post:
      operationId: addAudienceContacts
      tags: [Audiences]
      summary: Kontakte hinzufügen
      description: >
        Fügt einer Custom Audience Kontakte hinzu (append, ≤ 10.000). Rohe
        Kontakte im festen Schema, serverseitig gehasht.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { $ref: "#/components/schemas/AudienceContact" }
      responses:
        "200":
          description: Kontakte hinzugefügt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      contacts_received: { type: integer }
                      contacts_invalid: { type: integer }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: removeAudienceContacts
      tags: [Audiences]
      summary: Kontakte entfernen
      description: >
        Entfernt Kontakte aus einer Custom Audience (DSGVO-Opt-out). Gleiches
        Kontakt-Schema wie beim Hinzufügen.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { $ref: "#/components/schemas/AudienceContact" }
      responses:
        "200":
          description: Kontakte entfernt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      contacts_removed: { type: integer }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: Registrierte Webhooks lesen
      description: >
        Listet die Webhook-Endpoints des Kunden (ohne Signatur-Secret).
        Scope `reporting`.
      responses:
        "200":
          description: Liste der Endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string, format: uri }
                        events: { type: array, items: { type: string } }
                        status: { type: string, enum: [active, inactive] }
                        last_delivery_at: { type: string, format: date-time, nullable: true }
                        failure_count: { type: integer }
                        created_at: { type: string, format: date-time }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: registerWebhook
      tags: [Webhooks]
      summary: Webhook-URL registrieren
      description: >
        Registriert eine HTTPS-URL für Status-Push (ad.status_changed). Die
        Antwort enthält EINMALIG ein `signing_secret`; jede Zustellung wird damit
        per HMAC-SHA256 signiert (Header `X-CampaignKit-Signature: t=<unix>,v1=<hmac>`,
        gebildet über `"<t>.<roher Body>"`). Scope `full`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: HTTPS-Endpunkt der Kundenplattform.
                  example: "https://kunde.de/hooks/campaignkit"
                events:
                  type: array
                  items: { type: string }
                  default: [ad.status_changed]
                  example: [ad.status_changed]
      responses:
        "201":
          description: Webhook registriert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      url: { type: string, format: uri }
                      events: { type: array, items: { type: string } }
                      active: { type: boolean }
                      signing_secret:
                        type: string
                        description: Nur einmalig — danach nicht mehr abrufbar.
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /plan:
    get:
      operationId: getPlan
      tags: [Plan]
      summary: Vertragszustand + Stufe + voraussichtliche Gebühr
      description: >
        Liefert den Vertragszustand, ob Schreibzugriffe möglich sind, das laufende
        Zyklusfenster (am Vertrags-Starttag verankert), den bisherigen Spend der
        verwalteten Kampagnen, die voraussichtliche Gebühr (min über Stufen) und die
        Tarif-Staffel. Keine Tarifwahl: die Stufe ergibt sich rückwirkend aus dem
        Spend; solange aktiv gilt mindestens die Starter-Stufe. Beträge in Cent,
        netto. Scope `reporting`.
      responses:
        "200":
          description: Vertragszustand + Projektion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      account_status: { type: string, enum: [active, deactivated, past_due, pending, canceled] }
                      can_write: { type: boolean }
                      current_cycle:
                        type: object
                        nullable: true
                        properties:
                          start: { type: string, format: date }
                          end: { type: string, format: date }
                      spend_to_date_cents: { type: integer }
                      projected:
                        type: object
                        nullable: true
                        properties:
                          tier: { type: string }
                          fix_cents: { type: integer }
                          media_fee_cents: { type: integer }
                          total_cents: { type: integer }
                      last_billed:
                        type: object
                        nullable: true
                        properties:
                          tier: { type: string, nullable: true }
                          total_cents: { type: integer }
                      tiers:
                        type: array
                        items:
                          type: object
                          properties:
                            key: { type: string }
                            name: { type: string }
                            fix_cents: { type: integer }
                            media_fee_pct: { type: number }
                            spend_min_cents: { type: integer }
                            spend_max_cents: { type: integer, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /plan/usage:
    get:
      operationId: getPlanUsage
      tags: [Plan]
      summary: Laufender Zyklus + Spend + Projektion
      description: >
        Laufendes Zyklusfenster, bisher angefallener Spend der über campaignKit
        verwalteten Kampagnen und die voraussichtliche Gebühr. KEIN Limit — der
        Spend wird nie gedeckelt. Scope `reporting`.
      responses:
        "200":
          description: Zyklus + Spend + Projektion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cycle_start: { type: string, format: date, nullable: true }
                      cycle_end: { type: string, format: date, nullable: true }
                      spend_to_date_cents: { type: integer }
                      projected_tier: { type: string, nullable: true }
                      projected_fee_cents: { type: integer, nullable: true }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /account/deactivate:
    post:
      operationId: deactivateAccount
      tags: [Plan]
      summary: Konto deaktivieren (0 €, Selbst-Service)
      description: >
        Deaktiviert das Konto: Schreibzugriffe (neue oder aktive Ads) werden
        gesperrt, Lesen bleibt möglich. Der laufende Zyklus wird am Zyklusende noch
        abgerechnet, danach 0 € bis zur Reaktivierung. Die Reaktivierung erfolgt
        NICHT per API, sondern auf Anfrage beim Anbieter per E-Mail an
        kontakt@ventureon.io. Dabei kann eine Reaktivierungsgebühr anfallen
        (standardmäßig 179 € netto zzgl. USt; die Höhe richtet sich nach dem zum
        Zeitpunkt der Reaktivierung gültigen Tarif). Idempotent. Scope `reporting`
        (auch im bereits deaktivierten Zustand aufrufbar).
      responses:
        "200":
          description: Konto deaktiviert.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      account_status: { type: string, example: deactivated }
                      already_deactivated: { type: boolean }
                  request_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Kunden-API-Key als `Authorization: Bearer <key>`. Pro Kunde rotierbar,
        mit Scope (`full` oder `reporting`).

  responses:
    Unauthorized:
      description: Kein oder ungültiger API-Key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: API-Key gültig, aber Scope reicht nicht.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    PaymentRequired:
      description: >-
        Kein aktiver Schreibzugang (Konto deaktiviert, Zahlung überfällig, Checkout
        offen oder Vertrag beendet). Lesen bleibt im deaktivierten/überfälligen
        Zustand möglich.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Rate-Limit überschritten.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ValidationError:
      description: >-
        Ungültige Eingabe oder gesperrtes Feld gesetzt. Auch bei unvollständigem
        Onboarding des Kunden (fehlende Pflicht-Stammdaten wie Page, Instagram-Konto,
        DSA/legal_name oder unverifizierter Token); `details.missing` listet die
        fehlenden Schlüssel.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Ressource oder Endpunkt nicht gefunden (falsche ID oder unbekannter Pfad).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    MethodNotAllowed:
      description: HTTP-Methode für diesen Endpunkt nicht erlaubt.
      headers:
        Allow:
          description: Kommagetrennte Liste der erlaubten Methoden (RFC 9110).
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    QuotaExceeded:
      description: Plan-Limit (aktive Ad-Sets/Monat) erreicht — Upgrade nötig.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: Statuskollision (z. B. deaktivierte Ziel-Kampagne beim Anlegen/Ändern/Aktivieren von Ad-Sets oder Ads) oder Idempotenz-Konflikt.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    PublicTemplateSummary:
      type: object
      required: [id, name, description, type]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        type:
          type: string
          enum: [image, video]
          description: Medium der Vorlage. "video", sobald sie einen Video-Layer hat, sonst "image".
    PublicTemplateParam:
      type: object
      required: [name, type, required, label, description, min_chars, max_chars, example]
      properties:
        name: { type: string }
        type: { type: string, enum: [text, url, color, string] }
        required: { type: boolean }
        label: { type: string, nullable: true, description: Kurzer Anzeigename. }
        description:
          type: string
          nullable: true
          description: Hinweis, was inhaltlich in dieses Feld gehört (für KI-Inhalte).
        min_chars: { type: integer, nullable: true }
        max_chars: { type: integer, nullable: true }
        example: { type: string, nullable: true }
    PublicTemplateDetail:
      allOf:
        - $ref: "#/components/schemas/PublicTemplateSummary"
        - type: object
          required: [params]
          properties:
            params:
              type: array
              items: { $ref: "#/components/schemas/PublicTemplateParam" }
    TemplatePreviewUrls:
      type: object
      description: URL des Vorschau-Bild-Endpunkts je Format (1:1, 4:5, 9:16).
      properties:
        "1:1": { type: string, format: uri }
        "4:5": { type: string, format: uri }
        "9:16": { type: string, format: uri }
    TemplatePreviewParams:
      type: object
      required: [template, params, preview_urls]
      properties:
        template: { type: string, format: uuid }
        params:
          type: object
          additionalProperties: { type: string }
          description: Gespeicherte eigene Parameter-Werte (leer, wenn nichts gesetzt).
        preview_urls: { $ref: "#/components/schemas/TemplatePreviewUrls" }
    Audience:
      type: object
      properties:
        id: { type: string, format: uuid }
        meta_audience_id: { type: string, nullable: true }
        subtype: { type: string, enum: [CUSTOM, LOOKALIKE] }
        name: { type: string }
        description: { type: string, nullable: true }
        customer_file_source: { type: string, nullable: true }
        approximate_count: { type: integer, nullable: true }
        operation_status_code: { type: integer, nullable: true }
        delivery_status_code: { type: integer, nullable: true }
        ready: { type: boolean }
        origin_audience_id: { type: string, format: uuid, nullable: true }
        country: { type: string, nullable: true }
        ratio: { type: number, nullable: true }
        status: { type: string, enum: [active, deleted] }
        last_synced_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
    AudienceDetail:
      allOf:
        - $ref: "#/components/schemas/Audience"
        - type: object
          properties:
            operation_status: { type: string, nullable: true, description: Klartext-Beschreibung des Operation-Status. }
            delivery_status: { type: string, nullable: true, description: Klartext-Beschreibung des Delivery-Status. }
    AudienceContact:
      type: object
      additionalProperties: false
      description: >
        Roher CRM-Kontakt im festen Schema. Mindestens eines von email, phone
        oder external_id. Serverseitig normalisiert und gehasht (SHA-256);
        external_id (EXTERN_ID) wird nicht gehasht.
      properties:
        email: { type: string, example: "max@example.com" }
        phone: { type: string, example: "+4915112345678" }
        first_name: { type: string }
        last_name: { type: string }
        city: { type: string }
        state: { type: string }
        zip: { type: string, example: "50667" }
        country: { type: string, minLength: 2, maxLength: 2, example: DE }
        year_of_birth: { type: integer, minimum: 1900, maximum: 2100 }
        gender: { type: string, enum: [m, f] }
        external_id: { type: string }
    Error:
      type: object
      required: [error, request_id]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - payment_required
                - quota_exceeded
                - forbidden
                - not_found
                - validation_error
                - conflict
                - rate_limited
                - method_not_allowed
                - internal_error
                - not_implemented
            message:
              type: string
            details:
              description: Optionale, strukturierte Zusatzinfos.
        request_id:
          type: string
          format: uuid
