{
  "openapi": "3.1.0",
  "info": {
    "title": "NewsletterKit Public API",
    "version": "1.0.0",
    "description": "Einheitliche, versionierte API zwischen Kundenplattformen und Postmark. NewsletterKit ist eine interne Middleware: Templates (Block-Stapel aus MJML-Blöcken, im Dashboard gebaut oder aus dem Builder des Kundenportals gespeichert) werden serverseitig gerendert und der Versand wird an Postmark übergeben. Verschickt wird immer ein Template. Der mitgeschickte API-Key bestimmt Kunde und Plan; der Postmark-Server-Token verlässt niemals den Server.\n\nEmpfänger sind **transient** — sie werden über die Job-Laufzeit hinaus nicht gespeichert (keine PII).\n\n**Abrechnung:** Sie liegt vollständig bei Stripe. NewsletterKit meldet täglich die Zahl der versendeten E-Mails; Stripe bildet daraus eine Staffel (der Preis je E-Mail sinkt mit der Menge), rechnet monatlich rückwirkend ab und stellt das Ergebnis gemeinsam mit den anderen Produkten auf einer Rechnung. Es gibt keine Grundgebühr, kein Inklusivvolumen und keinen buchbaren Plan — und damit auch keine Endpunkte zum Buchen, Wechseln oder Kündigen.\n\n**Zugang:** Der Zugriff auf die API ist ein administrativer Schalter, kein Zahlungsstatus. Ist er gesperrt, bleiben lesende Endpunkte (Scope `reporting`) erreichbar, während versendende mit `403 forbidden` antworten. Die Freischaltung erfolgt über den Ansprechpartner.\n\nDiese Spec ist die **Single Source of Truth**: die Docs-Seite und das `llms.txt`-Bündel werden daraus generiert. Blöcke und Design-Regeln werden ausschließlich im Admin-Dashboard gepflegt; über die API sind sie nur lesbar. Block-Refs in einem Stapel referenzieren Blöcke über ihre unveränderliche `block_id` (UUID), nicht über den Slug. MJML-Templates unterstützen bedingte Abschnitte `{{#param}}…{{/param}}` (rendert nur bei nicht-leerem Wert). Newsletter gehen als Versandjob raus, Bilder kommen über den Asset-Upload, der Builder speichert seine Templates über die API.\n"
  },
  "servers": [
    {
      "url": "/api/v1",
      "description": "Versioniert unter /api/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "System",
      "description": "Verbindung & Health."
    },
    {
      "name": "Blocks",
      "description": "Freigeschaltete MJML-Blöcke des Kunden inkl. Parameter-Schema (read-only)."
    },
    {
      "name": "DesignRules",
      "description": "Design-Tokens des Kunden (am Render injiziert; read-only)."
    },
    {
      "name": "Rendering",
      "description": "Live-Vorschau (MJML + serverseitig kompiliertes, responsives HTML), versendet nichts."
    },
    {
      "name": "Templates",
      "description": "Templates des Kunden — lesen, aus dem Builder speichern, transaktional versenden."
    },
    {
      "name": "Assets",
      "description": "Bilder über NewsletterKit hochladen, optimiert und unter einer Hash-URL veröffentlicht."
    },
    {
      "name": "Usage",
      "description": "Versandmengen des Kunden aus dem Ledger, mit Monatslimit."
    },
    {
      "name": "SendJobs",
      "description": "Newsletter an viele Empfänger — Job anlegen, Empfänger anhängen, starten, abfragen."
    },
    {
      "name": "Messages",
      "description": "Status einer einzelnen Mail als Verlauf (über unsere Mail-ID)."
    },
    {
      "name": "Suppressions",
      "description": "Sperrspiegel von Postmark je Kunde und Stream — abholen, prüfen, nach Double-Opt-in aufheben."
    },
    {
      "name": "Billing",
      "description": "Tarif-Katalog, Abo-Status und Stripe-Self-Service (Checkout, Plan-Wechsel, Kündigung, Portal). Vom Plan-Gating ausgenommen — funktioniert auch ohne aktives Abo. Abgerechnet auf einer Stripe-Rechnung: Fixkosten monatlich im Voraus + Overage je 1.000 E-Mails nachgelagert (zzgl. 19 % USt.).\n"
    },
    {
      "name": "Webhooks",
      "description": "Ausgehende Newsletter-Events registrieren (send.completed/failed, stats.updated)."
    }
  ],
  "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`.\n",
        "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"
          }
        }
      }
    },
    "/blocks": {
      "get": {
        "operationId": "listBlocks",
        "tags": [
          "Blocks"
        ],
        "summary": "Freigeschaltete Blöcke lesen",
        "description": "Listet die für den Kunden freigeschalteten, aktiven Blöcke. Jeder Eintrag enthält die stabile `id` (UUID — der unveränderliche Referenzschlüssel), den `slug` (menschenlesbares Label) und das Parameter-Schema (`params`). Diese Liste sagt einem Client, welche Blöcke er in einer Komposition verwenden darf — referenziert wird ein Block über seine `id`, NICHT über den `slug` (Slugs/Namen können sich ändern, die `id` bleibt stabil und schützt gespeicherte Kompositionen). Es gibt keine Style-Bindung mehr: Jeder Block passt auf jeden Kunden, weil das Aussehen erst am Render aus dessen Design-Regeln entsteht. Welche Blöcke ein Kunde nutzen darf, entscheidet allein die Freischaltung. `series` nennt die Familie, aus der ein Block stammt — rein zum Gruppieren, eine Komposition darf Serien mischen. Read-only, Scope `reporting` genügt.\n",
        "responses": {
          "200": {
            "description": "Liste der freigeschalteten Blöcke.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "slug": {
                            "type": "string",
                            "example": "hero-text"
                          },
                          "name": {
                            "type": "string",
                            "example": "Hero-Text"
                          },
                          "tags": {
                            "type": "array",
                            "description": "Vom Admin gepflegte Tags des Blocks (mehrfach möglich). Für Logik zählt der `slug`, `label` ist nur die Anzeige. `header` und `footer` sind fest eingebaut.\n",
                            "items": {
                              "type": "object",
                              "properties": {
                                "slug": {
                                  "type": "string",
                                  "example": "content"
                                },
                                "label": {
                                  "type": "string",
                                  "example": "Inhalt"
                                }
                              }
                            }
                          },
                          "series": {
                            "type": "object",
                            "nullable": true,
                            "description": "Serie, aus der der Block stammt — höchstens eine, `null` wenn keine gesetzt ist. Blöcke derselben Serie sind zusammen entworfen und passen optisch zueinander; ein Client kann danach gruppieren. Keine Bindung: Eine Komposition darf Blöcke aus beliebig vielen Serien mischen. Für Logik zählt der `slug`, `label` ist nur die Anzeige.\n",
                            "properties": {
                              "slug": {
                                "type": "string",
                                "example": "journal"
                              },
                              "label": {
                                "type": "string",
                                "example": "Journal"
                              }
                            }
                          },
                          "description": {
                            "type": "string",
                            "nullable": true
                          },
                          "version": {
                            "type": "integer",
                            "example": 1
                          },
                          "params": {
                            "type": "array",
                            "items": {
                              "$ref": "#/components/schemas/BlockParam"
                            }
                          }
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/design-rules": {
      "get": {
        "operationId": "getDesignRules",
        "tags": [
          "DesignRules"
        ],
        "summary": "Design-Tokens des Kunden lesen",
        "description": "Gibt das vollständige, aufgelöste Design-Token-Set des Kunden zurück (Flächen-/Text-/Akzent-/Status-Farben, Typografie, Abstände, Komponenten; jeder Token bereits auf seinen konkreten Render-Wert aufgelöst). Diese Tokens werden serverseitig am Render injiziert und können über die API nicht verändert werden. Hat der Kunde keine eigenen Regeln, liefert die API vollständige Default-Tokens. Read-only, Scope `reporting`. Das Regelwerk steht unter /docs/design-rules.\n",
        "responses": {
          "200": {
            "description": "Design-Tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/DesignRules"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      }
    },
    "/render/preview": {
      "post": {
        "operationId": "renderPreview",
        "tags": [
          "Rendering"
        ],
        "summary": "Live-Vorschau rendern",
        "description": "Rendert den übergebenen Stapel (`blocks`) mit seinen `values` serverseitig zu MJML und kompiliertem, responsivem HTML — die Live-Vorschau des Builders im Kundenportal. Zustandslos: Nichts wird gespeichert, und das Ergebnis ist genau das, was ein Template mit diesem Stapel beim Versand ergäbe. Der MJML-Compiler läuft serverseitig (`stub: false`), `html` ist also fertiges, in jedem Mail-Client darstellbares HTML. Design-Regeln und Marken-Werte des Kunden werden über das Grundgerüst angewendet. Bedingte MJML-Abschnitte `{{#param}}…{{/param}}` werden ausgewertet: Parameter ohne Wert werden aus dem gerenderten HTML weggelassen. Block-Refs referenzieren Blöcke über die `block_id` (UUID), nicht den Slug. Alle referenzierten Blöcke müssen für den Kunden freigeschaltet und aktiv sein — sonst `403 forbidden`. Versendet nichts und legt nichts an. Scope `full`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "blocks"
                ],
                "properties": {
                  "blocks": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "$ref": "#/components/schemas/BlockRef"
                    }
                  },
                  "values": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Gerenderte Vorschau (kompiliertes, responsives HTML).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "mjml": {
                          "type": "string"
                        },
                        "html": {
                          "type": "string",
                          "description": "Fertiges, responsives HTML (serverseitig kompiliert)."
                        },
                        "errors": {
                          "type": "array",
                          "description": "MJML-Compiler-Warnungen/-Fehler (leer bei sauberem Render).",
                          "items": {
                            "type": "object",
                            "properties": {
                              "line": {
                                "type": "integer",
                                "nullable": true
                              },
                              "message": {
                                "type": "string"
                              },
                              "tagName": {
                                "type": "string",
                                "nullable": true
                              }
                            }
                          }
                        },
                        "stub": {
                          "type": "boolean",
                          "description": "Immer false — der MJML-Compiler läuft serverseitig.",
                          "example": false
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/blocks/{id}/tile": {
      "get": {
        "operationId": "getBlockTile",
        "tags": [
          "Blocks"
        ],
        "summary": "Kachel eines Blocks (HTML im Kundendesign)",
        "description": "Der Block mit seinen Beispielwerten, gerendert im Design des Kunden — fertiges HTML für ein iframe mit `srcdoc`. Gecacht; ändert sich mit Block oder Design-Regeln. Scope `reporting`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Die Kachel.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "block_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "slug": {
                          "type": "string"
                        },
                        "html": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/templates": {
      "get": {
        "operationId": "listTemplates",
        "tags": [
          "Templates"
        ],
        "summary": "Templates des Kunden mit Vertrag",
        "responses": {
          "200": {
            "description": "Alle Templates des Kunden.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Template"
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "operationId": "createTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Stapel aus dem Builder als Template speichern",
        "description": "Speichert einen Newsletter-Stapel als eigenes Template (`origin: customer`). Kein Footer im Stapel (kommt aus dem Rahmen des Kunden); Werte nur für Parameter des Blocks; `{{platzhalter}}` werden je Empfänger beim Versandjob gefüllt. Scope `full`, idempotent.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TemplateWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Angelegt.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Template"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/templates/{id}": {
      "get": {
        "operationId": "getTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Ein Template mit Stapel",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Das Template.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Template"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "put": {
        "operationId": "updateTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Kunden-Template ändern",
        "description": "Nur Templates mit origin customer; Dashboard-Templates sind über die API nur lesbar (403). Alle Felder optional.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TemplateWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Geändert.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Template"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "delete": {
        "operationId": "deleteTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Kunden-Template löschen",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Gelöscht."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/assets": {
      "post": {
        "operationId": "createAsset",
        "tags": [
          "Assets"
        ],
        "summary": "Bild anmelden, Upload-Adresse bekommen",
        "description": "Schritt 1 von 3. Die Datei geht danach per `PUT` mit ihrem `Content-Type` an `upload.url` (ohne API-Key, zwei Stunden gültig), dann `POST /assets/{id}/complete`. Scope `full`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "filename",
                  "content_type"
                ],
                "properties": {
                  "filename": {
                    "type": "string"
                  },
                  "content_type": {
                    "type": "string",
                    "enum": [
                      "image/png",
                      "image/jpeg",
                      "image/webp",
                      "image/gif",
                      "image/avif",
                      "image/svg+xml"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Angelegt, mit Upload-Adresse.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/Asset"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "upload": {
                              "type": "object",
                              "properties": {
                                "url": {
                                  "type": "string",
                                  "format": "uri"
                                },
                                "method": {
                                  "type": "string",
                                  "example": "PUT"
                                },
                                "headers": {
                                  "type": "object",
                                  "additionalProperties": {
                                    "type": "string"
                                  }
                                }
                              }
                            }
                          }
                        }
                      ]
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      },
      "get": {
        "operationId": "listAssets",
        "tags": [
          "Assets"
        ],
        "summary": "Fertige Bilder des Kunden",
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bilder, neueste zuerst.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Asset"
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/assets/{id}": {
      "get": {
        "operationId": "getAsset",
        "tags": [
          "Assets"
        ],
        "summary": "Ein Bild",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Das Bild.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Asset"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteAsset",
        "tags": [
          "Assets"
        ],
        "summary": "Bild löschen",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Gelöscht."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/assets/{id}/complete": {
      "post": {
        "operationId": "completeAsset",
        "tags": [
          "Assets"
        ],
        "summary": "Upload abschließen — optimieren und veröffentlichen",
        "description": "Schritt 3 von 3: Breite höchstens 1360 px, JPEG oder PNG (WebP/AVIF umgewandelt, SVG gerastert, animierte GIFs unverändert), unter 400 KB, ohne Metadaten. Die Adresse trägt den Inhalts-Hash und ändert sich nie. Gefahrlos wiederholbar. Scope `full`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fertig.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Asset"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Noch keine Datei hochgeladen, oder die Datei ist kein lesbares Bild."
          }
        }
      }
    },
    "/templates/{id}/send": {
      "post": {
        "operationId": "sendTemplate",
        "tags": [
          "Templates"
        ],
        "summary": "Transaktionale Mail aus einem Template versenden",
        "description": "Versendet eine transaktionale Mail aus dem Template `{id}` — synchron: Die Antwort kommt, wenn Postmark geantwortet hat, und trägt unsere Mail-ID. Das Template muss dem aufrufenden Kunden gehören und die Art transaktional haben; Newsletter gehen als Versandjob raus.\n\nDer Vertrag sind AUSSCHLIESSLICH die Template-Parameter: `params` liefert deren Werte; das Template leitet sie in Blöcke und Betreff, und `{{platzhalter}}` in festen Texten werden aus denselben Werten gefüllt. Unbekannte Parameter werden abgelehnt (422), fehlende Pflicht-Parameter ebenso. Pflicht-Parameter dürfen auch aus den Marken-Parametern des Kunden (Logo, Impressum …) gedeckt werden.\n\nAbsender: Standard ist die No-Reply-Adresse des Kunden (oder die Ausnahme des Templates). `from.email` darf den Teil vor dem `@` ändern; die Domain muss beim Kunden hinterlegt sein.\n\nGezählt wird, was Postmark annimmt (`status: accepted`). Im Sandbox-Modus des Kunden nimmt Postmark an, stellt nichts zu und es wird nichts gezählt. Scope `full`, idempotent über `Idempotency-Key`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "to"
                ],
                "properties": {
                  "to": {
                    "type": "string",
                    "format": "email",
                    "example": "mira@example.com"
                  },
                  "params": {
                    "type": "object",
                    "description": "Werte der Template-Parameter (siehe Template im Admin), z. B. titel oder link.\n",
                    "additionalProperties": {
                      "type": "string"
                    },
                    "example": {
                      "titel": "Dein Zugang zum Dashboard",
                      "name": "Mira",
                      "link": "https://kunde.de/einladung?token=…"
                    }
                  },
                  "from": {
                    "type": "object",
                    "description": "Absender-Ausnahme für diesen Aufruf.",
                    "properties": {
                      "email": {
                        "type": "string",
                        "format": "email",
                        "example": "lena.berger@kunde.de"
                      },
                      "name": {
                        "type": "string",
                        "example": "Lena Berger"
                      }
                    }
                  },
                  "reply_to": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Von Postmark beantwortet.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "message_id": {
                          "type": "string",
                          "format": "uuid",
                          "description": "Unsere Mail-ID — der Schlüssel für den Status der Mail."
                        },
                        "status": {
                          "type": "string",
                          "enum": [
                            "accepted",
                            "suppressed",
                            "invalid"
                          ],
                          "description": "accepted = von Postmark angenommen und gezählt; suppressed = Empfänger bei Postmark gesperrt (Bounce, Abmeldung); invalid = Adresse ungültig.\n"
                        },
                        "mode": {
                          "type": "string",
                          "enum": [
                            "sandbox",
                            "live"
                          ]
                        },
                        "template_id": {
                          "type": "string",
                          "format": "uuid"
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Zugang gesperrt, Versand nicht freigeschaltet oder ein Block nicht freigeschaltet.\n"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Template inaktiv oder Idempotency-Key-Konflikt."
          },
          "422": {
            "description": "Unbekannte oder fehlende (Pflicht-)Parameter, Newsletter-Template, Absender-Domain nicht hinterlegt oder ein Bild ließ sich nicht erzeugen (die Mail wurde dann nicht gesendet).\n"
          },
          "429": {
            "description": "Monatslimit des Kunden erreicht (code quota_exceeded)."
          }
        }
      }
    },
    "/usage": {
      "get": {
        "operationId": "getUsage",
        "tags": [
          "Usage"
        ],
        "summary": "Versandmengen (laufender Monat, Hochrechnung, Vormonate)",
        "description": "Gibt die Mengen zurück, die dieser Kunde versendet hat: laufender Monat (getrennt nach newsletter/transactional), eine lineare Hochrechnung auf den ganzen Monat und die drei Vormonate.\n\n**Keine Beträge und kein Kontingent.** Abgerechnet wird nach tatsächlichem Versand über eine Staffel, die in Stripe hinterlegt ist; was der Verbrauch kostet, steht auf der Stripe-Rechnung. Ein zweiter Ort mit Geldbeträgen wäre eine zweite Wahrheit.\n\nScope `reporting` — auch bei gesperrtem Zugang erreichbar.\n",
        "responses": {
          "200": {
            "description": "Mengen des Kunden.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Usage"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/send-jobs": {
      "post": {
        "operationId": "createSendJob",
        "tags": [
          "SendJobs"
        ],
        "summary": "Versandjob anlegen",
        "description": "Legt einen Job als Entwurf an. Das Template muss dem Kunden gehören, aktiv und ein Newsletter sein; `params` sind die Werte seiner Template-Parameter. `from.email` darf den Teil vor dem `@` ändern — die Domain muss beim Kunden hinterlegt sein. Scope `full`, idempotent.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "template_id"
                ],
                "properties": {
                  "template_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "params": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  },
                  "from": {
                    "type": "object",
                    "properties": {
                      "email": {
                        "type": "string",
                        "format": "email"
                      },
                      "name": {
                        "type": "string"
                      }
                    }
                  },
                  "reply_to": {
                    "type": "string",
                    "format": "email"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job angelegt (Status `draft`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/SendJob"
                    },
                    "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"
          }
        }
      },
      "get": {
        "operationId": "listSendJobs",
        "tags": [
          "SendJobs"
        ],
        "summary": "Jobs des Kunden",
        "parameters": [
          {
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "draft",
                "scheduled",
                "running",
                "paused",
                "completed",
                "cancelled",
                "failed"
              ]
            }
          },
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Die letzten Jobs, neueste zuerst.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/SendJob"
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/send-jobs/{id}": {
      "get": {
        "operationId": "getSendJob",
        "tags": [
          "SendJobs"
        ],
        "summary": "Status, Zähler und Auswertung eines Jobs",
        "description": "Zähler je Ergebnis, Summen aus den Postmark-Ereignissen (`stats`) und dieselben Zahlen je Stunde (`timeline`). Scope `reporting`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Der Job.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "allOf": [
                        {
                          "$ref": "#/components/schemas/SendJob"
                        },
                        {
                          "type": "object",
                          "properties": {
                            "stats": {
                              "$ref": "#/components/schemas/JobStats"
                            },
                            "timeline": {
                              "type": "array",
                              "items": {
                                "allOf": [
                                  {
                                    "type": "object",
                                    "properties": {
                                      "bucket": {
                                        "type": "string",
                                        "format": "date-time"
                                      }
                                    }
                                  },
                                  {
                                    "$ref": "#/components/schemas/JobStats"
                                  }
                                ]
                              }
                            }
                          }
                        }
                      ]
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/send-jobs/{id}/recipients": {
      "post": {
        "operationId": "appendSendJobRecipients",
        "tags": [
          "SendJobs"
        ],
        "summary": "Empfänger anhängen (bis 5.000 je Aufruf)",
        "description": "Nur im Entwurf. Jeder Empfänger bekommt sofort seine Mail-ID; eine Adresse ist je Job nur einmal drin. `data` füllt `{{platzhalter}}` dieses Empfängers. Scope `full`, idempotent je Aufruf.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "recipients"
                ],
                "properties": {
                  "recipients": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 5000,
                    "items": {
                      "type": "object",
                      "required": [
                        "email"
                      ],
                      "properties": {
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "data": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Angehängt.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "added": {
                          "type": "integer"
                        },
                        "duplicates": {
                          "type": "integer"
                        },
                        "total": {
                          "type": "integer"
                        },
                        "recipients": {
                          "type": "array",
                          "items": {
                            "type": "object",
                            "properties": {
                              "email": {
                                "type": "string",
                                "format": "email"
                              },
                              "message_id": {
                                "type": "string",
                                "format": "uuid"
                              }
                            }
                          }
                        }
                      }
                    },
                    "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"
          },
          "429": {
            "description": "Höchstzahl je Job erreicht (code quota_exceeded)."
          }
        }
      }
    },
    "/send-jobs/{id}/start": {
      "post": {
        "operationId": "startSendJob",
        "tags": [
          "SendJobs"
        ],
        "summary": "Job starten",
        "description": "Sofort oder zu `scheduled_at`. Prüft Freischaltung, Newsletter-Footer, Pflicht-Parameter und im Live-Modus das Monatslimit. Scope `full`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Gestartet (`running`) oder geplant (`scheduled`).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/SendJob"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Monatslimit erreicht (code quota_exceeded)."
          }
        }
      }
    },
    "/send-jobs/{id}/cancel": {
      "post": {
        "operationId": "cancelSendJob",
        "tags": [
          "SendJobs"
        ],
        "summary": "Job abbrechen",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Abgebrochen; angenommene Mails sind raus, alles Übrige nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/SendJob"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/send-jobs/{id}/messages": {
      "get": {
        "operationId": "listSendJobMessages",
        "tags": [
          "SendJobs"
        ],
        "summary": "Mails eines Jobs nach Status",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "accepted",
                "suppressed",
                "invalid",
                "failed",
                "unknown",
                "cancelled",
                "delivered",
                "bounced",
                "complained"
              ]
            }
          },
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 500
            }
          },
          {
            "in": "query",
            "name": "after",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Mails mit Zustand; `email` nur, solange der Job läuft.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "message_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "status": {
                            "type": "string"
                          },
                          "email": {
                            "type": "string",
                            "nullable": true
                          },
                          "bounce_type": {
                            "type": "string",
                            "nullable": true
                          },
                          "error": {
                            "type": "string",
                            "nullable": true
                          },
                          "delivered_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "first_opened_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "first_clicked_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "bounced_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          },
                          "complained_at": {
                            "type": "string",
                            "format": "date-time",
                            "nullable": true
                          }
                        }
                      }
                    },
                    "paging": {
                      "type": "object",
                      "properties": {
                        "after": {
                          "type": "string",
                          "nullable": true
                        },
                        "has_next": {
                          "type": "boolean"
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/send-jobs/{id}/links": {
      "get": {
        "operationId": "listSendJobLinks",
        "tags": [
          "SendJobs"
        ],
        "summary": "Klicks je Link",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Links, meistgeklickt zuerst.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "url": {
                            "type": "string"
                          },
                          "clicks": {
                            "type": "integer"
                          },
                          "unique_clicks": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/messages/{id}": {
      "get": {
        "operationId": "getMessage",
        "tags": [
          "Messages"
        ],
        "summary": "Status einer Mail als Verlauf",
        "description": "Der zuletzt bekannte Zustand einer Mail und ihr Verlauf aus den Postmark-Ereignissen. Die `id` ist die `message_id` aus der Antwort des Versands. Nur eigene Mails (sonst 404); die Adresse steht nirgends in der Antwort. Scope `reporting`.\n",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Zustand und Verlauf.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Message"
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/suppressions": {
      "get": {
        "operationId": "listSuppressions",
        "tags": [
          "Suppressions"
        ],
        "summary": "Sperränderungen seit Zeitpunkt",
        "description": "Alle Sperränderungen des Kunden seit `since`, chronologisch — gesetzte (`active: true`) und aufgehobene (`active: false`). Für einen Cron im Kundensystem, der sein Sperrfeld nachzieht. Die Einwilligung liegt im CRM, die Sperre bei Postmark; NewsletterKit spiegelt sie. Scope `reporting`.\n",
        "parameters": [
          {
            "in": "query",
            "name": "since",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Änderungen ab diesem Zeitpunkt (inklusive)."
          },
          {
            "in": "query",
            "name": "stream",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "newsletter",
                "transactional"
              ],
              "default": "newsletter"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500,
              "default": 500
            }
          },
          {
            "in": "query",
            "name": "after",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Cursor aus `paging.after` der vorigen Seite."
          }
        ],
        "responses": {
          "200": {
            "description": "Sperränderungen, chronologisch.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Suppression"
                      }
                    },
                    "paging": {
                      "type": "object",
                      "properties": {
                        "after": {
                          "type": "string",
                          "nullable": true
                        },
                        "has_next": {
                          "type": "boolean"
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/suppressions/lookup": {
      "post": {
        "operationId": "lookupSuppressions",
        "tags": [
          "Suppressions"
        ],
        "summary": "Adressen auf aktive Sperre prüfen",
        "description": "Prüft bis zu 500 Adressen gegen den Sperrspiegel. `POST` statt `GET`, damit Adressen nicht in URLs und Server-Logs landen. Scope `reporting`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "emails"
                ],
                "properties": {
                  "emails": {
                    "type": "array",
                    "maxItems": 500,
                    "items": {
                      "type": "string",
                      "format": "email"
                    }
                  },
                  "stream": {
                    "type": "string",
                    "enum": [
                      "newsletter",
                      "transactional"
                    ],
                    "default": "newsletter"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Status je Adresse.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "email",
                          "suppressed"
                        ],
                        "properties": {
                          "email": {
                            "type": "string",
                            "format": "email"
                          },
                          "suppressed": {
                            "type": "boolean"
                          },
                          "reason": {
                            "type": "string",
                            "enum": [
                              "unsubscribe",
                              "hard_bounce",
                              "spam_complaint"
                            ]
                          },
                          "reactivatable": {
                            "type": "boolean"
                          },
                          "since": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    },
    "/suppressions/reactivate": {
      "post": {
        "operationId": "reactivateSuppression",
        "tags": [
          "Suppressions"
        ],
        "summary": "Sperre nach neuem Double-Opt-in aufheben",
        "description": "Hebt die Sperre einer Adresse bei Postmark auf. Nur nach einem neuen, bestätigten Double-Opt-in — nie, weil jemand im CRM einen Haken gesetzt hat. Nach einer Spam-Beschwerde ist keine Aufhebung möglich: `409 conflict` mit `details.reason = \"spam_complaint\"`. Gefahrlos wiederholbar. Scope `full`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "stream": {
                    "type": "string",
                    "enum": [
                      "newsletter",
                      "transactional"
                    ],
                    "default": "newsletter"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sperre aufgehoben (oder war bereits aufgehoben).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "stream": {
                          "type": "string",
                          "enum": [
                            "newsletter",
                            "transactional"
                          ]
                        },
                        "reactivated": {
                          "type": "boolean"
                        }
                      }
                    },
                    "request_id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "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`.\n",
        "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"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "operationId": "registerWebhook",
        "tags": [
          "Webhooks"
        ],
        "summary": "Webhook-URL registrieren",
        "description": "Registriert eine HTTPS-URL für Newsletter-Events (`send.completed`, `send.failed`, `stats.updated`). Die Antwort enthält EINMALIG ein `signing_secret`; jede Zustellung wird damit per HMAC-SHA256 signiert (Header `X-NewsletterKit-Signature: t=<unix>,v1=<hmac>`, gebildet über `\"<t>.<roher Body>\"`). Scope `full`.\n",
        "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/newsletterkit"
                  },
                  "events": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "send.completed",
                        "send.failed",
                        "stats.updated"
                      ]
                    },
                    "default": [
                      "send.completed"
                    ],
                    "example": [
                      "send.completed",
                      "send.failed"
                    ]
                  }
                }
              }
            }
          }
        },
        "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"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Kunden-API-Key als `Authorization: Bearer <key>`. Pro Kunde rotierbar, mit Scope (`full` oder `reporting`). Demo-Key (Seed): `nk_demo_secret`.\n"
      }
    },
    "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"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Rate-Limit überschritten.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ValidationError": {
        "description": "Ungültige Eingabe.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "Idempotenz-Konflikt oder Statuskollision.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Ressource nicht gefunden (oder gehört nicht zum Kunden).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotImplemented": {
        "description": "Endpunkt noch nicht verfügbar (coming soon).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "BlockParam": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "example": "headline"
          },
          "label": {
            "type": "string",
            "nullable": true,
            "example": "Überschrift"
          },
          "type": {
            "type": "string",
            "enum": [
              "string",
              "text",
              "url",
              "image",
              "color",
              "boolean",
              "number"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "fallback": {
            "type": "string",
            "nullable": true
          },
          "hide_if_missing": {
            "type": "boolean"
          },
          "example": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true,
            "description": "Was inhaltlich in dieses Feld gehört. Der Parametername allein sagt das nicht — `title` verrät nicht, ob eine Schlagzeile oder ein Produktname erwartet wird. Für aufrufende Systeme geschrieben, ausdrücklich auch für Modelle, die ein Template automatisiert befüllen.\n",
            "example": "Schlagzeile der Veranstaltung, ohne Datum."
          },
          "min_chars": {
            "type": "integer",
            "nullable": true,
            "minimum": 0,
            "description": "Zugesagte Mindestlänge in Zeichen; `null` = keine Untergrenze.\n",
            "example": 10
          },
          "max_chars": {
            "type": "integer",
            "nullable": true,
            "minimum": 1,
            "description": "Zugesagte Höchstlänge in Zeichen; `null` = keine Obergrenze. Sie ist eine **Zusage, keine Schranke**: Der Versand weist längere Werte nicht zurück. Wer sie überschreitet, riskiert aber ein Layout, das sich in der verschickten Mail nicht mehr korrigieren lässt.\n",
            "example": 45
          },
          "is_list": {
            "type": "boolean",
            "description": "`true` = **Liste**: Der Parameter erwartet ein Array von Objekten in der Form seiner `fields`, nicht einen Einzelwert. Der Block wiederholt seinen Abschnitt je Element — die Zahl der Elemente ist frei, eine leere Liste lässt den Abschnitt entfallen.\nOhne diese Angabe wäre eine Liste nicht von einem zusammengesetzten Parameter zu unterscheiden: Beide melden `fields`, aber die eine will `[{…}, {…}]`, der andere `{…}`.\n",
            "example": false
          },
          "fields": {
            "type": "array",
            "description": "Die Bestandteile eines zusammengesetzten Parameters oder — bei `is_list: true` — die Form EINES Listenelements. Fehlt das Feld, trägt der Parameter einen einfachen Wert.\n",
            "items": {
              "$ref": "#/components/schemas/BlockParam"
            }
          }
        }
      },
      "BlockRef": {
        "type": "object",
        "required": [
          "block_id"
        ],
        "properties": {
          "block_id": {
            "type": "string",
            "format": "uuid",
            "description": "Unveränderliche `id` (UUID) eines für den Kunden freigeschalteten Blocks (aus `GET /v1/blocks`, Feld `id`) — NICHT der `slug`. Slugs und Namen können sich ändern, die `id` bleibt stabil und schützt gespeicherte Kompositionen vor Brüchen.\n",
            "example": "b1a2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
          },
          "values": {
            "type": "object",
            "description": "Param-Key → Wert. Ein Wert ist **Text** oder, bei einem zusammengesetzten Parameter, ein **Objekt mit seinen Feldern**.\nZusammengesetzt sind Parameter, die inhaltlich zusammengehören und sonst einzeln vergessen würden. Ein Bild etwa besteht aus Adresse und Alt-Text; welche Felder ein Parameter hat, sagt `GET /v1/blocks` im Feld `fields`.\nAlternativ dürfen die Felder auch flach mit Punkt übergeben werden (`\"bild.url\"`), was beim Zusammenbauen aus einer flachen Datenquelle hilft. Beides ergibt dasselbe Ergebnis.\nIst ein Parameter eine **Liste** (`GET /v1/blocks` meldet `is_list: true`), erwartet er ein **Array von Objekten** in der Form seiner `fields` — beliebig viele Elemente, ohne feste Obergrenze. Der Block wiederholt seinen Abschnitt je Element; eine leere Liste lässt ihn ganz entfallen. Die punktierte Kurzform gibt es hier nicht: Sie müsste die Position in den Schlüssel schreiben und damit die Länge festschreiben.\n",
            "additionalProperties": {
              "oneOf": [
                {
                  "type": "string"
                },
                {
                  "type": "object",
                  "additionalProperties": {
                    "type": "string"
                  }
                },
                {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  }
                }
              ]
            },
            "example": {
              "headline": "Hallo",
              "bild": {
                "url": "https://…/buehne.jpg",
                "alt": "Bühne bei Sonnenuntergang"
              },
              "artists": [
                {
                  "name": "MALUGI",
                  "genre": "Trance",
                  "instagram_url": "https://instagram.com/malugienergy/"
                },
                {
                  "name": "DARIA KOLOSOVA",
                  "genre": "Techno"
                }
              ]
            }
          }
        }
      },
      "Usage": {
        "type": "object",
        "description": "Reine Mengen. Preise stehen nicht darin: Abgerechnet wird nach Staffel in Stripe, und die Rechnung dort ist die einzige Wahrheit.\n",
        "properties": {
          "current_month": {
            "type": "object",
            "properties": {
              "period_month": {
                "type": "string",
                "format": "date",
                "example": "2026-09-01"
              },
              "emails_sent": {
                "type": "integer",
                "example": 12480
              },
              "emails_newsletter": {
                "type": "integer",
                "example": 9330
              },
              "emails_transactional": {
                "type": "integer",
                "example": 3150
              },
              "projected_emails": {
                "type": "integer",
                "nullable": true,
                "description": "Lineare Fortschreibung des bisherigen Tempos auf den ganzen Monat — eine Schätzung, keine Zusage. Am ersten Tag `null`.\n",
                "example": 41600
              },
              "days_elapsed": {
                "type": "integer",
                "example": 9
              },
              "days_in_month": {
                "type": "integer",
                "example": 30
              }
            }
          },
          "previous_months": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "period_month": {
                  "type": "string",
                  "format": "date"
                },
                "emails_sent": {
                  "type": "integer"
                },
                "emails_newsletter": {
                  "type": "integer"
                },
                "emails_transactional": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "Template": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "subject": {
            "type": "string",
            "nullable": true
          },
          "kind": {
            "type": "string",
            "enum": [
              "transaktional",
              "newsletter"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active",
              "archived"
            ]
          },
          "origin": {
            "type": "string",
            "enum": [
              "admin",
              "customer"
            ],
            "description": "admin = im Dashboard gebaut (über die API nur lesbar), customer = aus dem Builder gespeichert."
          },
          "from": {
            "type": "object",
            "nullable": true,
            "properties": {
              "email": {
                "type": "string",
                "format": "email"
              },
              "name": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "reply_to": {
            "type": "string",
            "nullable": true
          },
          "params": {
            "type": "array",
            "description": "Der Parameter-Vertrag (bei Kunden-Templates leer).",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "label": {
                  "type": "string",
                  "nullable": true
                },
                "type": {
                  "type": "string"
                },
                "required": {
                  "type": "boolean"
                },
                "fallback": {
                  "type": "string",
                  "nullable": true
                },
                "example": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          },
          "blocks": {
            "type": "array",
            "description": "Nur in der Detailansicht.",
            "items": {
              "type": "object",
              "properties": {
                "block_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "block_slug": {
                  "type": "string",
                  "nullable": true
                },
                "values": {
                  "type": "object"
                }
              }
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "TemplateWrite": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "subject": {
            "type": "string"
          },
          "blocks": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "block_id"
              ],
              "properties": {
                "block_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "values": {
                  "type": "object",
                  "description": "Werte je Parameter des Blocks; zusammengesetzte als Objekt, z. B. bild { url, alt }."
                }
              }
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "active"
            ]
          }
        }
      },
      "Asset": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "ready"
            ]
          },
          "filename": {
            "type": "string",
            "nullable": true
          },
          "content_type": {
            "type": "string",
            "nullable": true
          },
          "url": {
            "type": "string",
            "format": "uri",
            "nullable": true
          },
          "width": {
            "type": "integer",
            "nullable": true
          },
          "height": {
            "type": "integer",
            "nullable": true
          },
          "bytes": {
            "type": "integer",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "JobStats": {
        "type": "object",
        "properties": {
          "accepted": {
            "type": "integer"
          },
          "suppressed": {
            "type": "integer"
          },
          "delivered": {
            "type": "integer"
          },
          "bounced": {
            "type": "integer"
          },
          "opened": {
            "type": "integer"
          },
          "opened_unique": {
            "type": "integer"
          },
          "clicked": {
            "type": "integer"
          },
          "clicked_unique": {
            "type": "integer"
          },
          "complained": {
            "type": "integer"
          },
          "unsubscribed": {
            "type": "integer"
          }
        }
      },
      "SendJob": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "scheduled",
              "running",
              "paused",
              "completed",
              "cancelled",
              "failed"
            ]
          },
          "mode": {
            "type": "string",
            "enum": [
              "sandbox",
              "live"
            ]
          },
          "template_id": {
            "type": "string",
            "format": "uuid"
          },
          "params": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "from": {
            "type": "object",
            "nullable": true,
            "properties": {
              "email": {
                "type": "string",
                "format": "email"
              },
              "name": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "reply_to": {
            "type": "string",
            "nullable": true
          },
          "scheduled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "started_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "finished_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "paused_reason": {
            "type": "string",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true
          },
          "recipients": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer"
              },
              "accepted": {
                "type": "integer"
              },
              "suppressed": {
                "type": "integer"
              },
              "invalid": {
                "type": "integer"
              },
              "failed": {
                "type": "integer"
              },
              "pending": {
                "type": "integer"
              }
            }
          },
          "batches": {
            "type": "integer",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Message": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "type": "string",
            "enum": [
              "transactional",
              "newsletter"
            ]
          },
          "mode": {
            "type": "string",
            "enum": [
              "sandbox",
              "live"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "accepted",
              "suppressed",
              "invalid",
              "failed",
              "unknown",
              "delivered",
              "bounced",
              "complained"
            ]
          },
          "template_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "send_job_id": {
            "type": "string",
            "format": "uuid",
            "nullable": true
          },
          "subject": {
            "type": "string",
            "nullable": true
          },
          "bounce_type": {
            "type": "string",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "delivered_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "first_opened_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "first_clicked_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "bounced_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "complained_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "events": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "enum": [
                    "delivery",
                    "bounce",
                    "spam_complaint",
                    "open",
                    "click",
                    "subscription_change"
                  ]
                },
                "at": {
                  "type": "string",
                  "format": "date-time"
                },
                "details": {
                  "type": "object"
                }
              }
            }
          }
        }
      },
      "Suppression": {
        "type": "object",
        "description": "Eine Sperränderung aus dem Spiegel. `active: false` ist eine aufgehobene Sperre — auch die muss ins CRM.\n",
        "required": [
          "email",
          "stream",
          "active",
          "reason",
          "changed_at"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "stream": {
            "type": "string",
            "enum": [
              "newsletter",
              "transactional"
            ]
          },
          "active": {
            "type": "boolean"
          },
          "reason": {
            "type": "string",
            "enum": [
              "unsubscribe",
              "hard_bounce",
              "spam_complaint"
            ]
          },
          "reactivatable": {
            "type": "boolean",
            "description": "Bei `spam_complaint` immer `false`."
          },
          "changed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error",
          "request_id"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "bad_request",
                  "unauthorized",
                  "forbidden",
                  "not_found",
                  "validation_error",
                  "conflict",
                  "quota_exceeded",
                  "payment_required",
                  "rate_limited",
                  "internal_error",
                  "not_implemented"
                ]
              },
              "message": {
                "type": "string"
              },
              "details": {
                "description": "Optionale, strukturierte Zusatzinfos."
              }
            }
          },
          "request_id": {
            "type": "string",
            "format": "uuid"
          }
        }
      }
    }
  }
}