{
  "openapi": "3.0.3",
  "info": {
    "title": "HSC Versand-API — Grußkarten",
    "version": "1.0.0",
    "description": "Schnittstelle für den Versanddienstleister: fällige Grußkarten abrufen, quittieren, Rückläufer melden.\n\n**Der Ablauf in drei Schritten**\n\n1. `GET /grusskarten` regelmäßig abrufen — einmal täglich genügt. Die Antwort enthält standardmäßig alles, was in den nächsten 14 Tagen einzuliefern ist.\n2. Karten produzieren und einliefern. `einlieferungAb` ist der früheste sinnvolle Tag, `zustellungZum` der Tag, an dem die Karte ankommen soll.\n3. `POST /grusskarten/angenommen` mit den `ids` — danach sind sie aus dem Abruf raus. Kommt eine Karte später zurück: `POST /grusskarten/retoure`.\n\n**Vorlauf.** Karten stehen spätestens fünf Tage vor dem Zustelltag im Abruf, in der Regel deutlich früher. Wer täglich abruft, hat nie weniger als diese fünf Tage Zeit.\n\n**Zum Testen** `?probe=1` verwenden. Das liefert Vorschauzeilen mit denselben Feldern, erkennbar an `probe: true`. Was so markiert ist, wird nicht gedruckt.\n\n**Bremse.** 300 Anfragen je Minute und Schlüssel, 600 je Minute und IP. Darüber kommt 429 mit `Retry-After`; ein täglicher Abruf liegt weit darunter.\n\nFehler kommen einheitlich als `{ error }`, bei 422 zusätzlich mit `issues`.",
    "contact": {
      "name": "Haag Sondershausen Consulting GmbH",
      "url": "https://haag-sondershausen.de"
    },
    "license": {
      "name": "Proprietär — Nutzung nur durch beauftragte Versandpartner"
    }
  },
  "servers": [
    {
      "url": "https://api.haag-sondershausen.de"
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Der Schlüssel als 'Authorization: Bearer <key>'. Vergeben wird er in der Zentrale; gespeichert ist dort nur sein Hash, der Klartext ist nach dem Anlegen einmalig sichtbar und danach nicht mehr herstellbar. Geht er verloren, wird ein neuer erzeugt und der alte widerrufen."
      }
    },
    "schemas": {
      "Empfaenger": {
        "type": "object",
        "properties": {
          "anrede": {
            "type": "string",
            "nullable": true,
            "description": "Rufname für die Anrede, aufgerichtet („Max“). Null, wenn keiner taugt."
          },
          "vorname": {
            "type": "string",
            "nullable": true
          },
          "nachname": {
            "type": "string",
            "nullable": true
          },
          "strasse": {
            "type": "string",
            "nullable": true,
            "description": "Straße und Hausnummer in einer Zeile."
          },
          "plz": {
            "type": "string",
            "nullable": true,
            "description": "Führende Null ist enthalten — als String behandeln."
          },
          "ort": {
            "type": "string",
            "nullable": true
          },
          "land": {
            "type": "string",
            "nullable": true,
            "description": "ISO-3166-1 alpha-2, z. B. DE, AT, CH."
          }
        },
        "required": [
          "anrede",
          "vorname",
          "nachname",
          "strasse",
          "plz",
          "ort",
          "land"
        ]
      },
      "Kartentext": {
        "type": "object",
        "properties": {
          "anrede": {
            "type": "string",
            "description": "Anredezeile samt Komma, z. B. „Hallo Max,“."
          },
          "text": {
            "type": "string",
            "description": "Fließtext. Beginnt klein, weil die Anrede auf ein Komma endet."
          },
          "grussformel": {
            "type": "string",
            "description": "Schlusszeile, z. B. „Dein Team von Haag & Sondershausen“."
          },
          "innentext": {
            "type": "string",
            "description": "Alle drei Teile mit Leerzeilen verbunden — für Empfänger, die einen Block wollen."
          }
        },
        "required": [
          "anrede",
          "text",
          "grussformel",
          "innentext"
        ]
      },
      "Grusskarte": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Schlüssel für Quittung und Retoure."
          },
          "referenz": {
            "type": "string",
            "description": "Sprechender Schlüssel („geburtstag:2026:12345“). Für Lieferschein und Rückfragen."
          },
          "anlass": {
            "type": "string",
            "enum": [
              "geburtstag",
              "weihnachten",
              "funded"
            ]
          },
          "motiv": {
            "type": "string",
            "nullable": true,
            "description": "Welches Kartenmotiv. Null, solange es je Anlass nur eins gibt."
          },
          "empfaenger": {
            "$ref": "#/components/schemas/Empfaenger"
          },
          "text": {
            "$ref": "#/components/schemas/Kartentext"
          },
          "einlieferungAb": {
            "type": "string",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Frühestens einliefern. Vorher kommt die Karte zu früh an."
          },
          "zustellungZum": {
            "type": "string",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Der Tag, an dem sie im Briefkasten liegen soll — der Geburtstag selbst."
          },
          "charge": {
            "type": "string",
            "nullable": true,
            "description": "Bündel, zu dem die Karte gehört („2026-KW38“)."
          },
          "probe": {
            "type": "boolean",
            "description": "true = Vorschauzeile aus einem Trockenlauf. NICHT drucken, NICHT einliefern."
          }
        },
        "required": [
          "id",
          "referenz",
          "anlass",
          "motiv",
          "empfaenger",
          "text",
          "einlieferungAb",
          "zustellungZum",
          "charge",
          "probe"
        ]
      },
      "Abruf": {
        "type": "object",
        "properties": {
          "karten": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Grusskarte"
            }
          },
          "anzahl": {
            "type": "integer"
          },
          "bis": {
            "type": "string",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Bis zu diesem Einliefertag wurde geschaut."
          },
          "probe": {
            "type": "boolean",
            "description": "true = die Lieferung besteht aus Vorschauzeilen. Das passiert nur auf ausdrückliche Anfrage mit probe=1 oder solange der Versand insgesamt nicht scharf geschaltet ist."
          }
        },
        "required": [
          "karten",
          "anzahl",
          "bis",
          "probe"
        ]
      },
      "Quittung": {
        "type": "object",
        "properties": {
          "angenommen": {
            "type": "integer",
            "description": "Neu auf „übergeben“ gesetzt."
          },
          "bekannt": {
            "type": "integer",
            "description": "War schon quittiert — der Aufruf war eine Wiederholung und hat nichts geändert."
          },
          "unbekannt": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs, zu denen es keine Karte gibt."
          },
          "probe": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs von Vorschauzeilen (`probe: true`). Nicht quittiert — es gibt nichts zu produzieren. Sie stehen beim nächsten Probe-Abruf wieder da."
          }
        },
        "required": [
          "angenommen",
          "bekannt",
          "unbekannt",
          "probe"
        ]
      },
      "Retoure": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "referenz": {
            "type": "string"
          }
        },
        "required": [
          "ok"
        ]
      },
      "Uebersicht": {
        "type": "object",
        "properties": {
          "offen": {
            "type": "integer",
            "description": "Karten, die auf Abruf warten."
          },
          "uebergeben": {
            "type": "integer",
            "description": "Quittierte Karten insgesamt."
          },
          "retouren": {
            "type": "integer"
          },
          "naechsteEinlieferung": {
            "type": "string",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
            "description": "Wann das nächste Mal etwas eingeliefert werden muss."
          }
        },
        "required": [
          "offen",
          "uebergeben",
          "retouren",
          "naechsteEinlieferung"
        ]
      },
      "Fehler": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "issues": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {}
            },
            "description": "Nur bei 422: die Fundstellen der fehlgeschlagenen Prüfung."
          }
        },
        "required": [
          "error"
        ]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/grusskarten": {
      "get": {
        "operationId": "grusskartenAbrufen",
        "summary": "Fällige Karten abrufen",
        "description": "Liefert alle Karten, die bis zum Ende des Fensters eingeliefert werden müssen — einschließlich überfälliger. Die Antwort ist immer der vollständige Stand und kein Änderungs-Delta: Ein ausgefallener Abruf ist damit folgenlos, der nächste holt die Rückstände mit.\n\nMaßgeblich ist `einlieferungAb` — der Tag, an dem die Karte in die Post muss, damit sie am `zustellungZum` im Briefkasten liegt.\n\nEine Karte bleibt so lange in der Antwort, bis sie über `/grusskarten/angenommen` quittiert ist. Das ist Absicht: Sie zweimal zu sehen ist harmlos, eine verlorene ist es nicht.\n\n⚠️ Zeilen mit `probe: true` sind Vorschau und dürfen **nicht** gedruckt werden.",
        "tags": [
          "Grußkarten"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "enum": [
                "geburtstag",
                "weihnachten",
                "funded"
              ],
              "description": "Nur diesen Anlass. Ohne Angabe kommt alles, was ansteht."
            },
            "required": false,
            "description": "Nur diesen Anlass. Ohne Angabe kommt alles, was ansteht.",
            "name": "anlass",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "nullable": true,
              "minimum": 0,
              "maximum": 120,
              "description": "Wie viele Tage voraus. Standard 14."
            },
            "required": false,
            "description": "Wie viele Tage voraus. Standard 14.",
            "name": "tage",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "description": "Nur ein bestimmtes Bündel („2026-KW38“)."
            },
            "required": false,
            "description": "Nur ein bestimmtes Bündel („2026-KW38“).",
            "name": "charge",
            "in": "query"
          },
          {
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 2000
            },
            "required": false,
            "name": "limit",
            "in": "query"
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "0",
                "1"
              ],
              "description": "1 = Vorschauzeilen zum Anbinden und Testen statt echter Aufträge."
            },
            "required": false,
            "description": "1 = Vorschauzeilen zum Anbinden und Testen statt echter Aufträge.",
            "name": "probe",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Die fälligen Karten.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Abruf"
                }
              }
            }
          },
          "401": {
            "description": "Kein oder ungültiger Schlüssel.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "403": {
            "description": "Der Schlüssel trägt das nötige Recht nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "422": {
            "description": "Die Anfrage passt nicht zum Schema.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "429": {
            "description": "Zu viele Anfragen. `Retry-After` sagt, wann es weitergeht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          }
        }
      }
    },
    "/grusskarten/angenommen": {
      "post": {
        "operationId": "grusskartenQuittieren",
        "summary": "Karten als in Produktion quittieren",
        "description": "Bestätigt, dass die Karten übernommen wurden. Erst dadurch verschwinden sie aus dem Abruf.\n\nDer Aufruf ist idempotent: Dieselben `ids` ein zweites Mal zu senden ändert nichts und meldet sie als `bekannt`. Nach einem Netzabbruch kann also bedenkenlos wiederholt werden.\n\nVorschauzeilen (`probe: true`) lassen sich nicht quittieren — gedruckt wurde nichts. Sie kommen als `probe` zurück und stehen beim nächsten Probe-Abruf wieder da.",
        "tags": [
          "Grußkarten"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "minItems": 1,
                    "maxItems": 2000,
                    "description": "Die Karten, die in Produktion gegangen sind."
                  },
                  "externeReferenz": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "Eigene Auftrags- oder Chargennummer. Steht danach im Cockpit."
                  }
                },
                "required": [
                  "ids"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verbucht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Quittung"
                }
              }
            }
          },
          "401": {
            "description": "Kein oder ungültiger Schlüssel.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "403": {
            "description": "Der Schlüssel trägt das nötige Recht nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "422": {
            "description": "Die Anfrage passt nicht zum Schema.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "429": {
            "description": "Zu viele Anfragen. `Retry-After` sagt, wann es weitergeht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          }
        }
      }
    },
    "/grusskarten/retoure": {
      "post": {
        "operationId": "retoureMelden",
        "summary": "Rückläufer melden",
        "description": "Meldet eine Karte, die zurückgekommen ist. Der `grund` ist der wertvolle Teil: Eine Retoure ist der einzige verlässliche Hinweis darauf, dass eine Anschrift nicht mehr stimmt — sie wird im Kundenstamm nachgezogen.",
        "tags": [
          "Grußkarten"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "grund": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 500,
                    "description": "Warum die Karte zurückkam — „unbekannt verzogen“, „Annahme verweigert“."
                  },
                  "am": {
                    "type": "string",
                    "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
                    "description": "Tag des Rücklaufs. Ohne Angabe: heute."
                  }
                },
                "required": [
                  "id",
                  "grund"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Vermerkt.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Retoure"
                }
              }
            }
          },
          "401": {
            "description": "Kein oder ungültiger Schlüssel.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "403": {
            "description": "Der Schlüssel trägt das nötige Recht nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "404": {
            "description": "Zu dieser id gibt es keine Karte.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "422": {
            "description": "Die Anfrage passt nicht zum Schema.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "429": {
            "description": "Zu viele Anfragen. `Retry-After` sagt, wann es weitergeht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          }
        }
      }
    },
    "/uebersicht": {
      "get": {
        "operationId": "uebersichtLesen",
        "summary": "Zahlen zum Selbstprüfen",
        "description": "Wie viele Karten auf Abruf warten, wie viele quittiert sind, wie viele zurückkamen und wann das nächste Mal eingeliefert werden muss. Gedacht zum Anbinden und für einen Blick auf die eigene Integration.",
        "tags": [
          "Grußkarten"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Der Stand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Uebersicht"
                }
              }
            }
          },
          "401": {
            "description": "Kein oder ungültiger Schlüssel.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "403": {
            "description": "Der Schlüssel trägt das nötige Recht nicht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          },
          "429": {
            "description": "Zu viele Anfragen. `Retry-After` sagt, wann es weitergeht.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Fehler"
                }
              }
            }
          }
        }
      }
    }
  }
}