Entwickler

Die HTTP-Schnittstelle von Zeddly.

Dieselbe API, die auch das Dashboard benutzt — es gibt keine zweite, keine interne und keine bevorzugte. Was hier steht, ist vollständig: jeder Endpunkt, den es gibt, steht auf dieser Seite.

Basis-URL

https://api.zeddly.app

Authentifizierung

Angemeldet wird mit E-Mail und Passwort. Die Antwort setzt ein Sitzungscookie, und dieses Cookie schickst du bei jeder weiteren Anfrage mit. Es gibt heute keinen anderen Weg — insbesondere keine API-Schlüssel.

# Anmelden. Das Cookie landet in cookies.txt.
curl -sS -c cookies.txt \
  -X POST https://api.zeddly.app/api/auth/sign-in/email \
  -H 'Content-Type: application/json' \
  -d '{"email":"anna@kanzlei-weber.de","password":"…"}'

# Jede weitere Anfrage schickt es mit.
curl -sS -b cookies.txt https://api.zeddly.app/api/me
  • Registrieren, abmelden und Passwort zurücksetzen laufen über dieselbe Familie: POST /api/auth/sign-up/email, POST /api/auth/sign-out, POST /api/auth/request-password-reset.
  • Das Zurücksetzen antwortet immer mit Erfolg, auch für eine unbekannte Adresse. Die ehrliche Antwort würde jedem mit einem Formular verraten, wer unsere Kundinnen sind.
  • Die Sitzung trägt die aktive Organisation. Nach dem Anmelden ist das die älteste Mitgliedschaft; gewechselt wird mit POST /api/me/organization.
  • Aus dem Browser heraus geht das nicht von deiner eigenen Domain: CORS ist auf die Zeddly-Frontends beschränkt. Server-zu-Server ist davon nicht betroffen — und weil es noch keine widerrufbaren Schlüssel gibt, hieße das heute, ein Passwort in einem Dienst zu hinterlegen. Wir empfehlen das nicht.

Konventionen

JSON, UTF-8, ein Präfix
Alles liegt unter /api. Anfragen mit Rumpf sind application/json; charset=utf-8, die einzige Ausnahme ist der Vorlagen-Upload (multipart/form-data). Antworten sind immer JSON — außer GET /api/templates/:id/pdf, das die Datei selbst liefert.
Zeitstempel sind ISO-8601
Jedes createdAt, updatedAt und archivedAt ist ein String in UTC, zum Beispiel 2026-08-28T09:14:22.031Z. Es gibt keine Unix-Timestamps.
Jede Anfrage nennt genau eine Organisation
Die Organisation kommt aus der Sitzung, nicht aus dem Pfad und nicht aus einem Header. Jede Abfrage ist auf sie gefiltert; gewechselt wird mit POST /api/me/organization. Eine ID aus einer anderen Organisation ergibt 404, nicht 403 — die Ressource existiert für diese Sitzung schlicht nicht.
Fehler haben immer dieselbe Form
message ist ein deutscher Satz, den du unverändert anzeigen kannst. code und details kommen dazu, wenn es etwas Maschinenlesbares gibt — bislang nur bei DUPLICATE_ADDRESSES.
Unbekannte Felder werden verworfen
Rümpfe werden mit zod geprüft. Was nicht im Schema steht, fällt weg, statt gespeichert zu werden — ein Feld, das du schickst und das hier nicht dokumentiert ist, hat keine Wirkung.
Aus dem Browser nur von erlaubten Herkünften
CORS ist auf die Zeddly-Frontends beschränkt und die Sitzung hängt an einem Cookie mit SameSite=Lax. Aufrufe von deiner eigenen Domain aus dem Browser funktionieren nicht; von deinem Server aus schon.

Konto und Organisation

Wer ruft hier an, und in wessen Namen. Ein angemeldetes Konto ohne Organisation ist ein gültiger Zustand — genau so sieht eine frische Registrierung aus — deshalb sind organization und role hier als Einzige nullbar.

GET/api/me200

Wer ist angemeldet, mit welcher Rolle, in welcher Organisation.

Voraussetzung

Sitzung. Ausdrücklich keine Organisation nötig.

Antwort

{
  "user": {
    "id": "66cf…",
    "email": "anna@kanzlei-weber.de",
    "name": "Anna Weber",
    "userRole": "user"
  },
  "organization": {
    "id": "66d0…",
    "name": "Weber & Partner",
    "slug": "weber-partner",
    "createdAt": "2026-08-01T07:12:00.000Z"
  },
  "role": "admin",
  "organizations": [ /* alle Mitgliedschaften */ ]
}

Fehler

  • 401Keine gültige Sitzung.

Hinweise

  • organization und role sind null, solange das Konto keiner Organisation angehört. Das ist kein Fehler, sondern die Antwort, auf die der Registrierungsablauf wartet.
  • user.userRole ist die Plattformrolle (user für jede Kundin, admin nur für Zeddly). role ist die Rolle in dieser einen Organisation. Die beiden sind verschiedene Felder auf verschiedenen Datensätzen und dürfen nie verglichen werden.
POST/api/me/organizations201

Eine Organisation anlegen und in ihr weiterarbeiten.

Voraussetzung

Sitzung. Ausdrücklich keine Organisation nötig — hier entsteht die erste.

Anfrage

{ "name": "Weber & Partner" }

Antwort

Dasselbe Me-Objekt wie GET /api/me, jetzt mit der neuen Organisation als aktiver.

Fehler

  • 400Name kürzer als 2 oder länger als 120 Zeichen.
  • 400Für den Namen ließ sich keine freie Kennung finden.
  • 401Keine gültige Sitzung.

Hinweise

  • Der Name ist nicht eindeutig und wird es nicht: zwei echte Firmen heißen beide „Müller GmbH“. Eindeutig ist der daraus abgeleitete slug.
  • Wer anlegt, wird admin dieser Organisation — und nur dieser.
POST/api/me/organization200

Die aktive Organisation der Sitzung wechseln.

Voraussetzung

Sitzung, plus Mitgliedschaft in der Zielorganisation.

Anfrage

{ "organizationId": "66d0…" }

Antwort

Das Me-Objekt mit der neuen aktiven Organisation.

Fehler

  • 400organizationId fehlt oder ist leer.
  • 403Das Konto gehört dieser Organisation nicht an.
  • 404Es gibt keine Organisation mit dieser ID.

Hinweise

  • Die Wahl steht auf der Sitzung, nicht im Client. Jede folgende Anfrage ist auf diese Organisation gefiltert, bis wieder gewechselt wird.

Vorlagen

Eine Vorlage ist das, worauf die Briefe einer Kampagne aufgebaut werden. Es gibt zwei Arten und sie sind hinter einem Namen wirklich verschiedene Dinge: uploaded ist ein fertiges PDF, das Zeddly VERMISST und später überdruckt — composed ist ein in Zeddly geschriebener Brief, der je Empfänger NEU GESETZT wird. Beide werden gegen die Schablone der Deutschen Post geprüft.

GET/api/templates200

Alle Vorlagen der Organisation.

Voraussetzung

Sitzung + Organisation.

Anfrage

?archived=true — archivierte Vorlagen mit auflisten. Ohne den Parameter: nur aktive. Sortiert nach createdAt, neueste zuerst.

Antwort

LetterTemplate[] — siehe „Objekte“.

Fehler

  • 401Keine gültige Sitzung.
  • 403Das Konto gehört keiner Organisation an.
GET/api/templates/:id200

Eine Vorlage mit ihrer vollständigen Messung.

Voraussetzung

Sitzung + Organisation.

Antwort

Ein LetterTemplate inklusive analysis.

Fehler

  • 404Keine Vorlage mit dieser ID in dieser Organisation. Auch bei einer syntaktisch ungültigen ID.
POST/api/templates201

Ein PDF hochladen und vermessen lassen.

Voraussetzung

Sitzung + Organisation.

Anfrage

Content-Type: multipart/form-data

name=Mandantenschreiben 2026
file=@brief.pdf

Antwort

Das angelegte LetterTemplate, samt analysis und usable.

Fehler

  • 400Kein Name, oder länger als 120 Zeichen.
  • 400Die Datei ist leer, kein PDF, oder größer als 20 MB.
  • 400Das PDF ließ sich nicht lesen oder hat mehr Seiten als verarbeitet werden.

Hinweise

  • Der Name wird bewusst nicht aus dem Dateinamen abgeleitet. Brief_final_v4_NEU (2).pdf ist kein Name, den jemand auf einem Bildschirm lesen will.
  • Die Antwort enthält bereits das Prüfergebnis. usable: false heißt: aus dieser Vorlage entstehen keine Briefe — warum, steht in analysis.problems.
  • Die wichtigste Einzelfrage ist analysis.placeholdersArePaintedWhite. Ist sie false, druckt die Vorlage ihre eigenen Musterdaten unter jede echte Anschrift, und keinem Brief im Lauf sieht man es an.
POST/api/templates/composed201

Einen Brief in Zeddly schreiben statt ihn hochzuladen.

Voraussetzung

Sitzung + Organisation.

Anfrage

{
  "name": "Mahnung Stufe 2",
  "content": {
    "senderLines": ["Weber & Partner", "Rheinpromenade 10, 40789 Monheim"],
    "place": "Monheim",
    "subject": "Ihre offene Rechnung 4821",
    "salutation": "Guten Tag {{anrede}} {{nachname}},",
    "body": {
      "text": "wir haben Ihre Zahlung bis heute nicht erhalten.",
      "marks": [{ "from": 22, "to": 29, "style": "bold" }]
    },
    "closingPhrase": "Mit freundlichen Grüßen",
    "closingName": "Anna Weber",
    "fontSizePt": 10
  }
}

Antwort

Das angelegte LetterTemplate mit kind: "composed", content und der Messung des erzeugten Korrekturabzugs.

Fehler

  • 400Mehr als zwei Absenderzeilen — darunter beginnt der DVF-Sperrbereich.
  • 400Leerer Brieftext, oder länger als 20 000 Zeichen.
  • 400Eine Formatierung ist leer, rückwärts, oder reicht über das Textende hinaus.
  • 400fontSizePt ist nicht 9, 10, 11 oder 12.

Hinweise

  • In diesem Rumpf stehen keine Koordinaten, und das ist der Sicherheitsgewinn. Wo Text landet, entscheidet der Briefrahmen, der einmal nach der Schablone gebaut und bei jedem Satz gegen sie geprüft wird. Eine Angabe, die Text in den Sperrbereich setzt, lässt sich hier nicht formulieren.
  • Formatierungen sind Bereiche [from, to) über den Text, keine Auszeichnung im String. Grund: {{vorname}} wird je Empfängerin ersetzt und ändert dabei die Länge — die Bereiche wandern mit, **fett** würde verrutschen.
  • Die erzeugte PDF-Datei ist ein Korrekturabzug mit Musterdaten. Dass darin Musterwerte sichtbar sind, ist der Zweck eines Abzugs und kein Mangel — anders als bei einer hochgeladenen Vorlage.
PUT/api/templates/:id/content200

Den Text einer geschriebenen Vorlage ersetzen und neu setzen.

Voraussetzung

Sitzung + Organisation.

Anfrage

Derselbe Rumpf wie POST /api/templates/composed.

Antwort

Das aktualisierte LetterTemplate mit frischer Messung.

Fehler

  • 400Die Vorlage ist ein hochgeladenes PDF und lässt sich so nicht bearbeiten.
  • 400Die Vorlage ist archiviert.
  • 404Keine Vorlage mit dieser ID in dieser Organisation.

Hinweise

  • PUT und nicht PATCH: der Rumpf ist der ganze Brief. Ein Teilupdate hieße, zwei Fassungen eines Briefes zu verschmelzen und das Ergebnis zu drucken.
GET/api/templates/:id/pdf200

Die PDF-Datei der Vorlage.

Voraussetzung

Sitzung + Organisation.

Antwort

application/pdf, mit Content-Disposition: inline.

Fehler

  • 404Keine Vorlage mit dieser ID, oder zu ihr gibt es keine Datei.

Hinweise

  • Es gibt keine vorsignierten Links. Eine Datei kommt durch diesen Endpunkt, mit Sitzung und Organisationsfilter im Weg — ein signierter Link wäre ein Inhaberschlüssel auf einen Brief an eine echte Person und überlebt in Verlauf und Referer.
POST/api/templates/:id/archive200

Eine Vorlage aus der Auswahl nehmen.

Voraussetzung

Sitzung + Organisation.

Antwort

Das LetterTemplate mit gesetztem archivedAt.

Fehler

  • 404Keine Vorlage mit dieser ID in dieser Organisation.

Hinweise

  • Archivieren löscht nichts. Kampagnen, die diese Vorlage benutzt haben, verweisen weiter auf sie.
  • Antwortet 200 und nicht 201: hier entsteht nichts, es wird ein Datum auf einem vorhandenen Datensatz gesetzt.

Kampagnen

Eine Kampagne ist ein Versand: ein Code, eine Vorlage, eine Empfängerliste, viele Briefe. Angelegt werden Kampagnen heute nur im Dashboard — über die API lassen sie sich lesen und in den Versand geben.

GET/api/campaigns200

Alle Kampagnen der Organisation, mit Zählern.

Voraussetzung

Sitzung + Organisation.

Antwort

Campaign[] — siehe „Objekte“.

Fehler

  • 401Keine gültige Sitzung.
  • 403Das Konto gehört keiner Organisation an.

Hinweise

  • counts wird bei jeder Abfrage aus den Briefen gezählt und nicht mitgeführt. Damit kann es nicht auseinanderlaufen — und es ist der Grund, die Liste nicht in einer Schleife abzufragen.
GET/api/campaigns/:code200

Eine Kampagne über ihren Code.

Voraussetzung

Sitzung + Organisation.

Antwort

Eine Campaign.

Fehler

  • 404Keine Kampagne mit diesem Code in dieser Organisation.

Hinweise

  • Adressiert wird über den code, nicht über die id. Ein Versand = ein Code. Ein anderer Code heißt: ein anderer Brief — und die Dublettenprüfung zählt je Code.
POST/api/campaigns/:code/queue200

Die Entwürfe einer Kampagne an den Versand übergeben.

Voraussetzung

Sitzung + Organisation + Rolle admin.

Anfrage

{ "allowDuplicates": false }

Antwort

{ "queued": 418 }

Fehler

  • 403Die Rolle in dieser Organisation ist marketing oder viewer.
  • 404Keine Kampagne mit diesem Code in dieser Organisation.
  • 409Zwei Entwürfe teilen sich eine Anschrift. code: "DUPLICATE_ADDRESSES", details listet die Adressschlüssel.

Hinweise

  • Das ist der einzige schreibende Aufruf, den ein Client auf einen Brief machen darf. Er setzt Entwürfe auf queued. Jeden weiteren Übergang — sent, failed, skipped — macht ausschließlich der Versandprozess. Zwei Schreiber auf einem Brief sind nur so lange harmlos, wie diese Zeile hält.
  • allowDuplicates: true übergeht die Dublettenprüfung. Das ist die Entscheidung eines Menschen vor dem Bildschirm und gehört in keinen automatisierten Aufruf: ein eingelieferter Brief lässt sich nicht zurückholen. Es gibt bei der Deutschen Post keinen Endpunkt dafür — im Ernstfall waren es zwei identische Briefe an eine Adresse, und das ist schon passiert.
  • Antwortet 200 und nicht 201: es entsteht nichts, vorhandene Briefe wechseln den Zustand.

Betrieb

Der einzige Endpunkt ohne Sitzung und ohne das /api-Präfix.

GET/health200

Antwortet die API, und steht die Datenbank.

Voraussetzung

Keine.

Antwort

{ "ok": true, "db": 1 }

Fehler

  • 503Die Datenbank ist nicht erreichbar. ok: false, db ist der Verbindungszustand.

Hinweise

  • 503 ist hier eine Aussage über die Bereitschaft, kein Fehler — die Antwort hat denselben Rumpf wie im Erfolgsfall.

/api/admin/* gibt es ebenfalls. Es ist die einzige Stelle im System, an der eine Abfrage über Organisationsgrenzen hinweggeht, es ist ausschließlich lesend, und es steht hinter der Plattformrolle admin, die nur Zeddly-Mitarbeitende haben. Kundenkonten bekommen dort 403. Um Daten einer Kundin zu ändern, wechseln wir in ihre Organisation und benutzen dieselben Endpunkte wie alle anderen — eine Supportsitzung betrifft immer genau eine Organisation und steht im Protokoll.

Objekte

Die Formen, die oben zurückkommen. Felder mit sind gekürzt, nicht optional; optionale Felder sind als solche markiert.

Campaign

Ein Versand. counts wird bei jeder Abfrage aus den Briefen gezählt.

{
  "id": "66d1…",
  "code": "WEBER-2026-08",          // ein Versand = ein Code
  "name": "Mandantenschreiben August",
  "status": "drafts-checked",       // draft | list-rejected | drafts-checked | processing | completed
  "source": "standalone",           // standalone | shopify
  "shopDomain": "…",                // nur bei source: "shopify"
  "templateId": "66d2…",
  "counts": {
    "drafts": 418, "queued": 0, "sent": 0,
    "failed": 0, "skipped": 2,
    "printCentre": 0                // davon im Druckzentrum eingeliefert
  },
  "createdAt": "2026-08-01T07:12:00.000Z",
  "updatedAt": "2026-08-27T15:40:11.884Z"
}

LetterTemplate

Eine Vorlage. kind unterscheidet die beiden Arten; usable ist aus analysis abgeleitet und wird nie unabhängig davon gespeichert.

{
  "id": "66d2…",
  "kind": "uploaded",               // uploaded | composed
  "name": "Mandantenschreiben 2026",
  "filename": "brief.pdf",          // nur bei uploaded
  "storageKey": "…",                // ein Schlüssel, keine URL
  "content": { … },                 // nur bei composed
  "analysis": {
    "pageCount": 2,
    "pageSize": { "widthMm": 210, "heightMm": 297 },
    "isA4Portrait": true,
    "placeholdersArePaintedWhite": true,   // true | false | null
    "placeholders": [ /* TemplateTextRun */ ],
    "dvfIntrusions": [],                   // jeder Eintrag hier blockiert
    "recipientBlock": { "x": 23, "y": 69, "width": 85, "lineHeight": 3.88, "lines": 5 },
    "zones": [ { "zone": "empfaenger", "target": "69,0–90,0 mm", "measured": "…", "verdict": "ok" } ],
    "problems": [ { "code": "placeholders-visible", "severity": "blocker", "message": "…" } ]
  },
  "usable": true,
  "createdAt": "…",
  "updatedAt": "…",
  "archivedAt": "…"               // nur wenn archiviert
}

Me

Wer ruft an. organization und role sind zusammen null oder zusammen gesetzt.

{
  "user": { "id": "…", "email": "…", "name": "…", "userRole": "user" },
  "organization": { "id": "…", "name": "…", "slug": "…", "createdAt": "…" } | null,
  "role": "admin" | "marketing" | "viewer" | null,
  "organizations": [ /* jede Mitgliedschaft, für den Umschalter */ ]
}

Fehler

Jede fehlgeschlagene Anfrage. message ist zur Anzeige gedacht, code zum Auswerten.

{
  "message": "3 Adressen kommen mehrfach vor. Versand abgebrochen.",
  "code": "DUPLICATE_ADDRESSES",
  "details": ["anna|weber|hauptstr 1|40789|monheim|DE", …]
}

Platzhalter

In Anrede und Brieftext einer geschriebenen Vorlage. Sie werden je Empfängerin ersetzt — deshalb sind Formatierungen Bereiche über den Text und keine Auszeichnung im String: jede Ersetzung verschiebt alles dahinter.

PlatzhalterFeldIm Abzug
{{anrede}}AnredeFrau
{{vorname}}VornameAnna
{{nachname}}NachnameMuster
{{strasse}}StraßeMusterstraße 123
{{plz}}PLZ12345
{{ort}}OrtMusterstadt

Grenzen

WasGrenzeWarum
JSON-Rumpf2 MBEmpfängerlisten kommen als Upload, nicht als JSON.
Vorlagen-Upload20 MB, eine DateiWird schon während der Übertragung abgewiesen, nicht erst danach.
Brieftext einer geschriebenen Vorlage20 000 ZeichenEtwa sechs Seiten. Der Satz bricht bei acht ab und sagt das.
Formatierungen je Brieftext2 000Eine Auszeichnung je Zeichen ist ein Fehler, keine Betonung.
Absenderzeilen2Der Absenderbereich ist 5,5 mm hoch; darunter beginnt der Sperrbereich der Deutschen Post.
Schriftgrad9, 10, 11 oder 12 ptJeder Grad ist ein eigener geprüfter Briefrahmen, kein Intervall.
Organisationen je Konto20Eine Vernunftgrenze, keine Preisstufe.
Anfragen je Minutekeine feste GrenzeNoch keine Zusage. Zähler bitte nicht in einer Schleife abfragen.