Docs durchsuchen
Guides und API-Endpunkte durchsuchen
API-Dokumentation
Public API · docs.newsletterkit.ventureon.io · v1 · Newsletter-Middleware zu Postmark
Einführung
Die NewsletterKit API ist eine interne Middleware zwischen Kundenplattformen und Postmark: dein Portal schickt einfache, immer gleiche Requests — wir rendern die Mail aus MJML-Blöcken im Design des Kunden und übergeben den Versand an Postmark. Verschickt wird immer ein Template: transaktional einzeln und synchron, Newsletter als Versandjob. Jede Mail bekommt eine ID von uns; Auswertung und Sperren liegen hier, das Portal fragt ab. Empfängeradressen werden nach dem Versand gelöscht.
https://api.newsletterkit.ventureon.io/v1Blöcke → Template → Vorschau → Versand → Mail-IDllms-full.txt mit den fünf Abläufen, dem Einwilligungs-Modell und der Referenz) — damit Coding-KIs (Claude Code, Cursor) ein Portal anbinden, ohne nachzufragen.Maschinenlesbar
Quickstart · die fünf Abläufe
Ein schmaler Wrapper, dann fünf Abläufe, die jedes Kundenportal braucht: transaktionale Mail, Newsletter aus fertigem Template, Newsletter aus dem Builder, Sperren ins CRM, Auswertungsseite. Beispiele sind auf Next.js (TypeScript, fetch) ausgelegt — der API-Key lebt ausschließlich serverseitig.
0 · API-Key hinterlegen und Verbindung prüfen
.env.local
// .env.local
NEWSLETTERKIT_API_KEY=nk_live_xxxxxxxxxxxxxxxxlib/newsletterkit.ts
// lib/newsletterkit.ts — schmaler Server-Wrapper (nur serverseitig nutzen)
const BASE = "https://api.newsletterkit.ventureon.io/v1";
export async function newsletterkit<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.NEWSLETTERKIT_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
if (!res.ok) throw new Error(`NewsletterKit ${res.status}: ${await res.text()}`);
return res.json() as Promise<T>;
}Route Handler
// app/api/check/route.ts (Next.js Route Handler)
import { newsletterkit } from "@/lib/newsletterkit";
export async function GET() {
const health = await newsletterkit("/health");
return Response.json(health); // { data: { status: "ok", api_version: "v1" }, … }
}1 · Transaktionale Mail
Ein fertiges Template des Kunden, ein Empfänger, sofort. Die Antwort trägt unsere Mail-ID — sie gehört an die Bestellung oder den Nutzer, damit das Portal später den Status nachfragen kann.
- GET /v1/templates — Template der Art transaktional wählen, params lesen (name, label, type, required, example).
- POST /v1/templates/{id}/send mit to und params; Idempotency-Key gegen doppelten Versand bei Retries.
- Bei Bedarf GET /v1/messages/{id}: zugestellt oder gebounct, mit Grund.
Transaktionale Mail · Next.js
// Server Action / Route Handler
import { newsletterkit } from "@/lib/newsletterkit";
const { data } = await newsletterkit<{ data: { message_id: string; status: string } }>(
"/templates/00000000-0000-4000-8000-0000000000f3/send",
{
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({
to: "mira@example.com",
params: {
titel: "Dein Zugang zum Dashboard",
name: "Mira",
text: "Du bist als Redaktion eingetragen.",
link: "https://kunde.de/einladung?token=…",
screenshot: "https://kunde.de/bilder/dashboard.jpg",
},
}),
},
);
// data.status: accepted | suppressed | invalid — data.message_id am Nutzer speichern
await db.user.update({ where: { id: userId }, data: { inviteMailId: data.message_id } });4 · Sperren ins CRM
Die Einwilligung liegt im CRM, die Sperre bei Postmark. NewsletterKit spiegelt die Sperren; das CRM holt sie ab und leitet daraus ab, wer eine Mail bekommen darf. Ein Cron alle paar Minuten reicht — beim Versand sperrt Postmark ohnehin zuletzt.
- Cron: GET /v1/suppressions?since=<letzter Stand>, Seiten über paging.after; je Eintrag das Sperrfeld des Kontakts setzen (active=true) oder aufheben (active=false).
- Einzelfall: POST /v1/suppressions/lookup mit bis zu 500 Adressen.
- Nach neuem Double-Opt-in: POST /v1/suppressions/reactivate — 409 bei Spam-Beschwerde, die bleibt.
Sperren ins CRM · Next.js
// app/api/cron/suppressions/route.ts — alle 5 Minuten (Vercel Cron)
import { newsletterkit } from "@/lib/newsletterkit";
type Suppression = { email: string; active: boolean; reason?: string; changed_at: string };
export async function GET() {
let since = (await kv.get<string>("nk:suppressions_since")) ?? "1970-01-01T00:00:00Z";
let after: string | null = null;
do {
const page = await newsletterkit<{ data: Suppression[]; paging: { after: string | null; has_next: boolean } }>(
`/suppressions?since=${since}${after ? `&after=${after}` : ""}`,
);
for (const s of page.data) {
// Nur das Sperrfeld — die Einwilligung fasst der Abgleich nie an.
await db.contact.updateMany({
where: { email: s.email },
data: { emailSuppressed: s.active, emailSuppressedReason: s.active ? s.reason : null },
});
since = s.changed_at;
}
after = page.paging.has_next ? page.paging.after : null;
} while (after);
await kv.set("nk:suppressions_since", since);
return Response.json({ ok: true });
}5 · Auswertungsseite
Die Auswertung liegt bei NewsletterKit, das Portal rendert nur: drei Aufrufe, fertige Zahlen. Öffnungen und Klicks laufen noch Tage nach dem Versand ein.
- GET /v1/send-jobs/{id} — Zähler je Ergebnis, stats (Summen) und timeline (je Stunde).
- GET /v1/send-jobs/{id}/messages?status=bounced — wer nicht erreicht wurde, über Mail-IDs.
- GET /v1/send-jobs/{id}/links — Klicks je Link.
Auswertungsseite · Next.js
import { newsletterkit } from "@/lib/newsletterkit";
export async function newsletterReport(jobId: string) {
const [job, bounced, links] = await Promise.all([
newsletterkit<{ data: { status: string; recipients: Record<string, number>; stats: Record<string, number>; timeline: unknown[] } }>(`/send-jobs/${jobId}`),
newsletterkit<{ data: { message_id: string; bounce_type: string | null }[] }>(`/send-jobs/${jobId}/messages?status=bounced`),
newsletterkit<{ data: { url: string; clicks: number; unique_clicks: number }[] }>(`/send-jobs/${jobId}/links`),
]);
return { job: job.data, bounced: bounced.data, links: links.data };
}Authentifizierung & Header
Jeder API-Call trägt den API-Key im Authorization-Header als Bearer-Token. Der Key bestimmt den Kunden — dahinter liegt der Postmark-Server-Token, den das Portal nie sieht. Scopes: full (lesen + schreiben/versenden) oder reporting (read-only).
Authorization-Header
Authorization: Bearer nk_live_xxxxxxxxxxxxxxxxServer-only: Key niemals im Browser/Client-Bundle verwenden. In Next.js nur in Route Handlers, Server Actions oder Server Components. Einzige Ausnahme ohne Key: der PUT der Bilddatei an die signierte Upload-Adresse.
Standard-Header
| Header | Pflicht | Beschreibung |
|---|---|---|
| Authorization | immer | Bearer <API-Key>. Bestimmt Kunde + Plan. Niemals clientseitig im Browser verwenden. |
| Content-Type | bei JSON-Body | application/json — für alle POST/PUT-Requests mit JSON-Body. |
| Idempotency-Key | empfohlen (POST/PUT) | Eindeutige UUID pro Erstellung/Versand. Wiederholte Requests mit gleichem Key erzeugen keine Dubletten (kein doppelter Versand). |
| NewsletterKit-Version | optional | Pinnt die API-Version (z. B. v1). Ohne Header gilt die Account-Default-Version. |
| X-Request-Id | optional | Eigene Korrelations-ID; wird in Logs & Support-Anfragen zurückgegeben. |
Zugang zur Doku
SSO-Login → volle Docs
llms-full.txt — Abläufe, Regeln, Referenz
/docs/openapi.yamlKonventionen
Gelten für alle Endpunkte: einheitlicher { data, request_id }-Envelope, Cursor-Pagination bei Listen, Rate-Limits und Idempotenz.
Pagination (Cursor)
Listen mit Cursor (Sperren, Mails eines Jobs) liefern data[] + paging.after. Den Cursor an die nächste Anfrage als after übergeben, bis has_next false ist.
Envelope
{
"data": [ { "…": "…" } ],
"paging": { "after": "QVFIUm...", "has_next": true }
}
// nächste Seite:
GET /v1/suppressions?since=…&after=QVFIUm...&limit=500Rate-Limits
1.000 Anfragen je Minute und Kunde. Jede Antwort trägt X-RateLimit-*. Bei 429 die Sekundenzahl aus Retry-After abwarten (exponentielles Backoff empfohlen). Ein 429 mit quota_exceeded ist etwas anderes: eine Sicherung des Kunden (Monatslimit, Höchstzahl je Job), die ein Admin anhebt.
429-Antwort
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
Retry-After: 12Idempotenz
Bei POST einen Idempotency-Key (UUID) mitsenden. Der erste Aufruf wird ausgeführt und seine Antwort 24 Stunden gespeichert; Wiederholungen mit gleichem Key bekommen dieselbe Antwort (Idempotent-Replayed: true) — vor allem kein doppelter Versand bei Netzwerk-Retries. Gleicher Key mit anderem Body: 409. Antwortet Postmark nicht (500 mit message_id), bleibt der Key belegt: Die Wiederholung bekommt dieselbe Antwort und sendet nicht erneut — den Ausgang zeigt GET /v1/messages/{id}.
Personalisierung
{{platzhalter}} in Betreff und festen Texten eines Templates werden je Empfänger gefüllt — aus den Job-Parametern und den data des Empfängers, der Empfänger gewinnt. Im HTML maskiert, unbekannte Platzhalter verschwinden. Gerendert wird einmal je Job, danach nur noch ersetzt.
Fehler
Einheitliches Fehler-Objekt mit stabilem code, lesbarer message und request_id für den Support. 4xx = dein Request, 5xx = wir/Postmark.
Fehler-Schema
{
"error": {
"code": "validation_error",
"message": "Pflichtfelder fehlen: link.",
"details": [
{ "path": "link", "message": "link ist erforderlich." }
]
},
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}| Status | Code | Bedeutung |
|---|---|---|
| 400 | bad_request | Request-Format ungültig (JSON-Syntax, fehlende Felder). |
| 401 | unauthorized | API-Key fehlt, ungültig oder widerrufen. |
| 403 | forbidden | Key-Scope deckt diese Operation nicht (z. B. reporting-only bei schreibenden Calls). |
| 404 | not_found | Ressource existiert nicht oder gehört nicht zum Kunden (falsche ID). |
| 409 | conflict | Idempotenz-Konflikt oder Statuskollision. |
| 422 | validation_error | Felder vorhanden, aber inhaltlich ungültig (siehe details[]). |
| 429 | rate_limited | Zu viele Requests → Retry-After beachten. |
| 429 | quota_exceeded | Monatslimit oder Höchstzahl je Job erreicht — eine Sicherung des Kunden, im Admin anhebbar. |
| 5xx | internal_error | Fehler bei uns oder bei Postmark. Mit Backoff erneut versuchen. |
Zugang & Abrechnung
Es gibt keinen buchbaren Plan und kein Kontingent. Abgerechnet wird, was Postmark angenommen hat — nach einer Staffel, die in Stripe hinterlegt ist. Im Sandbox-Modus des Kunden nimmt Postmark an, stellt nichts zu und es wird nichts berechnet.
NewsletterKit meldet täglich die Zahl der angenommenen E-Mails an Stripe. Dort entsteht daraus die Rechnung — gemeinsam mit den anderen Produkten, die derselbe Kunde nutzt. Beträge, Stufen und Rechnungen stehen deshalb ausschließlich in Stripe; GET /v1/usage liefert Mengen, das Monatslimit und den Rest.
Der Zugang ist davon getrennt und ein rein administrativer Schalter. Ist er gesperrt, antworten versendende Endpunkte (/v1/templates/{id}/send, /v1/send-jobs/…/start) mit 403 forbidden, während lesende Endpunkte erreichbar bleiben. Dazu kommt die Freischaltung des Versands je Kunde: Erst wenn Postmark-Server, Broadcast-Stream, verifizierte Absender und beide Footer im Admin grün sind, geht etwas raus.
Webhooks
Geplant. Heute holt das Portal ab: Job-Status über GET /v1/send-jobs/{id}, Mail-Status über GET /v1/messages/{id}, Sperren über GET /v1/suppressions — idempotent, ohne Zustand bei uns. Die Zustellung ans Portal (job.completed, message.bounced, suppression.changed) kommt später als Zusatz auf dieselben Daten; die Registrierung einer URL ist schon möglich.
Signatur verifizieren (so wird es aussehen)
Header X-NewsletterKit-Signature hat die Form t=<unix>,v1=<hmac>. Der HMAC-SHA256 wird über "<t>.<roher Body>" mit deinem Webhook-Secret gebildet. Immer verifizieren, bevor du das Event verarbeitest.
Verifizierung · Next.js
// app/api/webhooks/newsletterkit/route.ts — geplant, so wird es aussehen
import crypto from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text();
// Header-Form: t=<unix>,v1=<hmac>
const header = req.headers.get("X-NewsletterKit-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const expected = crypto
.createHmac("sha256", process.env.NEWSLETTERKIT_WEBHOOK_SECRET!)
.update(`${parts.t}.${raw}`) // HMAC über "<t>.<roher Body>"
.digest("hex");
if (parts.v1 !== expected) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(raw); // { event: "job.completed", … }
// … schnell 200 zurückgeben, Verarbeitung asynchron
return new Response("ok");
}Event-Payload
Beispiel
{
"event": "job.completed",
"delivery_id": "wd_2026_0001",
"send_job_id": "f90c0326-…",
"recipients": { "total": 12000, "accepted": 11812, "suppressed": 188 },
"occurred_at": "2026-09-05T08:04:00Z"
}Events werden PII-frei sein — nur IDs, Status und Aggregate, nie Empfänger-Adressen. Die Abholung bleibt der Weg der Wahl, solange ein Portal keine Echtzeit braucht.
Einwilligung & Sperren
Zwei Fakten, zwei Systeme: Die Werbeeinwilligung liegt im Kundensystem, die Sperre (Abmeldung, Bounce, Beschwerde) bei Postmark. NewsletterKit spiegelt die Sperren zum Abholen — und das Kundensystem leitet daraus ab, wer eine Mail bekommen darf. Das Beispiel dazu steht oben im Ablauf „Sperren ins CRM“.
- Die Einwilligung liegt im Kundensystem. Die Werbeeinwilligung ist ein dokumentierter Akt: Double-Opt-in mit Zeitpunkt, Text und Herkunft. NewsletterKit und Postmark kennen sie nicht und fragen nie danach. Das Feld gehört dem Kundensystem, und nur das Kundensystem schreibt es.
- Die Sperre liegt bei Postmark. Abmeldung, Hard Bounce und Spam-Beschwerde entstehen bei Postmark und werden dort durchgesetzt: Ein gesperrter Empfänger wird je Nachricht abgewiesen, egal was das Kundensystem über ihn glaubt. NewsletterKit spiegelt Sperren und gibt sie weiter; übersteuern kann sie niemand.
- „Darf E-Mails empfangen“ ist eine Ableitung, kein Feld. Einwilligung vorhanden UND keine aktive Sperre. Im CRM sind das zwei getrennte Felder: marketing_consent schreibt nur das Kundensystem, email_suppressed schreibt nur der Abgleich mit NewsletterKit. Wer beide zu einem Boolean verschmilzt, verliert die Wiederkehr: Ein Abgemeldeter, der sich neu einträgt, hätte wieder Einwilligung, bliebe aber bei Postmark gesperrt.
- Abgleich durch Abholen. Das Kundensystem ruft regelmäßig GET /v1/suppressions?since=<letzter Stand> und schreibt die Antwort in sein Sperrfeld — gesetzte wie aufgehobene Sperren. Ein Cron alle paar Minuten reicht: Beim Versand sperrt Postmark ohnehin zuletzt, ein verspäteter Abgleich kostet höchstens eine abgewiesene Nachricht.
- Wiederkehr nur nach neuem Double-Opt-in. Wer sich abgemeldet hat und später neu einträgt, bleibt bei Postmark gesperrt, bis das Kundensystem POST /v1/suppressions/reactivate ruft. Das geschieht nach der Bestätigung, nie beim Setzen eines Hakens. Nach einer Spam-Beschwerde gibt es keine Reaktivierung; die Adresse bleibt dauerhaft gesperrt.
- Der Abmeldelink kommt von Postmark. Newsletter enthalten den Abmeldelink über den Footer des Kunden; die Adresse dahinter setzt Postmark je Empfänger ein, und die Abmeldung führt auf Postmarks Abmeldeseite. Der Aufrufer übergibt keinen unsubscribe_url. Eine Abmeldung gilt für alle Newsletter dieses Kunden — Listen und Segmente kennt Postmark nicht.
- Transaktionale Mails bleiben erreichbar. Sperren gelten je Stream. Ein abgemeldeter Empfänger bekommt weiterhin transaktionale Mails wie Double-Opt-in, Bestellbestätigung oder Passwort-Reset; gesperrt sind nur Newsletter.
- Listen und Segmente gehören dem Kundensystem. NewsletterKit kennt keine Listen. Welche Kontakte einen Newsletter bekommen, entscheidet das Kundensystem beim Zusammenstellen der Empfänger — gefiltert nach Einwilligung und Sperre. Adressen reisen nur mit dem Versandjob und werden nach dessen Abschluss gelöscht; es bleibt der Hash und die Mail-ID.
Das Sperrfeld im CRM schreibt ausschließlich der Abgleich. Ein Haken, den jemand von Hand entfernt, ändert bei Postmark nichts: Der nächste Versand weist die Adresse trotzdem ab.
Versionierung
Die Version steckt im Pfad-Präfix (/v1). Innerhalb von v1 sind nur additive, nicht-brechende Änderungen erlaubt — neue Felder/Endpunkte kommen hinzu, bestehende verschwinden nicht.
- Pinnen: optionaler Header
NewsletterKit-Version: v1fixiert das Verhalten unabhängig vom Account-Default. - Breaking Changes erscheinen nur in einer neuen Major-Version (
/v2) — niemals in/v1. - Deprecation: abgekündigte Versionen laufen mind. 6 Monate weiter; Hinweise via
Sunset-Header + Changelog.
Referenz
11 Gruppen · 31 EndpunkteJede Ressourcen-Gruppe hat eine eigene Seite mit allen Endpunkten, Parametern und Beispielen.
System
Verbindung & Health. Der einfachste Endpunkt — prüft API-Key und Erreichbarkeit.
1 EndpunktBlöcke
Liefert die für den Kunden freigeschalteten MJML-Blöcke samt Parameter-Schema. Welche Blöcke ein Template verwenden darf, kommt aus dieser Liste — referenziert wird ein Block immer über seine unveränderliche id (UUID), nicht über den slug. Blöcke selbst werden ausschließlich im Admin gepflegt.
2 EndpunkteDesign-Regeln
Liefert das vollständige, aufgelöste Design-Token-Set des Kunden (Flächen-/Text-/Akzent-/Status-Farben, Typografie, Abstände, Komponenten). Diese Tokens werden beim Render in jedes MJML-Dokument injiziert und erzwingen ein konsistentes Erscheinungsbild über alle Newsletter hinweg. Das vollständige Regelwerk (welches Token wofür) steht unter /docs/design-rules.
1 EndpunktRendering
Rendert einen Block-Stapel serverseitig zu MJML + kompiliertem, responsivem HTML — die Live-Vorschau des Builders im Kundenportal, zustandslos und ohne etwas zu versenden. MJML-Templates unterstützen bedingte Abschnitte {{#param}}…{{/param}}, die nur bei nicht-leerem Wert rendern (optionale Elemente wie Logo oder AGB-Link verschwinden sonst).
1 EndpunktTemplates
Verschickt wird immer ein Template. Es gehört dem Kunden — im Dashboard gebaut (origin admin, mit Parameter-Vertrag) oder aus dem Builder des Kundenportals gespeichert (origin customer, feste Werte, {{platzhalter}} je Empfänger). Struktur und Blöcke liegen serverseitig fest, das Aussehen kommt aus den Design-Regeln des Kunden, der Footer aus seinem Rahmen.
6 EndpunkteVerbrauch
Zeigt die versendeten Mengen: laufender Monat, Hochrechnung, Vormonate. Keine Beträge — abgerechnet wird nach Staffel in Stripe.
1 EndpunktBilder
Bilder für Newsletter: Das Portal lädt sie über NewsletterKit hoch und bekommt eine öffentliche, unveränderliche https-Adresse zurück — optimiert für Mail-Clients (höchstens 1360 px breit, JPEG oder PNG, unter 400 KB, ohne Metadaten). Zwei Schritte, weil ein Aufruf höchstens 4,5 MB annimmt: anmelden, Datei an die signierte Adresse schicken, abschließen.
5 EndpunkteVersandjobs
Ein Newsletter geht an viele Empfänger und wird als Versandjob verschickt: anlegen (Template, Parameter, Absender, optionaler Startzeitpunkt), Empfänger in Blöcken anhängen, starten, abfragen. Ein Worker rendert einmal, schickt Batches an Postmark und hält je Empfänger das Ergebnis fest. Die Empfängerliste baut das Kundensystem aus seinem CRM — gefiltert nach Einwilligung und Sperre.
8 EndpunkteMails
Der Status einer einzelnen Mail. Jede Mail bekommt beim Versand eine ID von uns; das Portal schreibt sie an seine Bestellung oder seinen Kontakt und fragt hier nach — zugestellt, geöffnet, gebounct mit Grund, gesperrt. Die Adresse steht nirgends in der Antwort.
1 EndpunktSperren
Der Sperrspiegel: Abmeldung, Hard Bounce und Spam-Beschwerde entstehen bei Postmark und werden dort je Nachricht durchgesetzt. Hier holt das Kundensystem die Änderungen ab, prüft Adressen und hebt Sperren nach neuem Double-Opt-in auf. Die Einwilligung selbst liegt im CRM des Kunden — „darf E-Mails empfangen“ ist die Ableitung aus beidem.
3 EndpunkteWebhooks
Bald verfügbarZustellung von Ereignissen ans Kundenportal — geplant. Heute holt das Portal Job-Status (GET /v1/send-jobs/{id}), Mail-Status (GET /v1/messages/{id}) und Sperren (GET /v1/suppressions) ab; das reicht für Builder, Versand und Auswertung. Die Zustellung kommt später als Zusatz auf dieselben Daten: job.completed, message.bounced, suppression.changed. Die Registrierung einer URL ist schon möglich, die Zustellung noch nicht.
2 Endpunkte