# Persolz API-Dokumentation

> REST-API v1.1.0 · Basis-URL `https://persolz.com/api/v1` · 127 Endpunkte · 17 Webhook-Ereignisse  
> Online-Version mit Code-Beispielen in cURL, PHP, JavaScript und Python sowie API-Konsole: https://persolz.com/api-docs  
> OpenAPI 3.1: https://persolz.com/api/v1/openapi.json  
> Diese Datei wird aus `app/Lib/ApiSpec.php` und den Anleitungen der Online-Doku erzeugt (Stand 15.10.2026). Die aktuelle Fassung gibt es immer unter https://persolz.com/api-docs/markdown.

## Inhalt

- [Einführung](#einfuehrung)
- [Schnellstart in 5 Minuten](#schnellstart)
- [Authentifizierung](#authentifizierung)
- [Fehler](#fehler)
- [Rate-Limits](#rate-limits)
- [Paginierung](#paginierung)
- [Idempotenz](#idempotenz)
- [CORS & Browser](#cors)
- [Platzhalter](#platzhalter)
- [Webhooks](#webhooks)
- [Funnel-Integration (z. B. kartenherz.com)](#funnel)
- [Transaktionale E-Mails per API mit Platzhaltern](#transaktional)
- [Seinfeld-Mails per API planen](#seinfeld-planen)
- [Posteingang per API](#posteingang)
- [Fehlerbehandlung & Wiederholungen](#wiederholungen)
- [API-Referenz](#api-referenz)
  - [System](#ref-system)
  - [Konto & Tarif](#ref-account)
  - [E-Mail-Listen](#ref-lists)
  - [Kontakte](#ref-contacts)
  - [Abmeldungen](#ref-unsubscribes)
  - [E-Mails](#ref-emails)
  - [Newsletter](#ref-newsletters)
  - [Seinfeld-Mails](#ref-seinfeld)
  - [Seifenopern](#ref-sequences)
  - [Vorlagen](#ref-templates)
  - [Anhänge](#ref-attachments)
  - [SMTP-Server](#ref-smtp)
  - [Kampagnen & Analytics](#ref-campaigns)
  - [A/B-Tests](#ref-ab-tests)
  - [Termine](#ref-appointments)
  - [Posteingang](#ref-inbox)
  - [KI](#ref-ai)
  - [Webhooks](#ref-webhooks)
  - [Protokolle](#ref-logs)
  - [Öffentliche Anmeldung](#ref-public)
  - [Integrationen](#ref-integrations)
- [Fehlercodes](#fehlercodes)
- [OpenAPI & Markdown](#downloads)
- [Changelog](#changelog)

<a id="einfuehrung"></a>
## Einführung

Verbinde Persolz mit Deinem Shop, Deinem Funnel oder Deinem CRM: Kontakte pflegen, Newsletter versenden, Seinfeld-Mails planen, Seifenopern starten – und über Webhooks sofort erfahren, was passiert.

### So funktioniert die API

Die Persolz-API ist eine klassische **REST-API**: Jede Ressource (Kontakt, Liste, Newsletter …) hat eine eigene URL, Du arbeitest mit den HTTP-Methoden `GET` (lesen), `POST` (anlegen oder auslösen), `PATCH` (teilweise ändern), `PUT` (ersetzen) und `DELETE` (löschen). Daten gehen als **JSON** (UTF-8) hin und zurück.

- **Alles, was die App kann:** Kontakte und Listen, E-Mails und Newsletter, Seinfeld-Mails, Seifenopern, Vorlagen, Anhänge, A/B-Tests, Termine, KI-Texte, Statistiken und Protokolle.
- **Verständliche Fehler:** Jede Fehlerantwort hat einen festen Code und eine deutsche Meldung, die sagt, was zu tun ist.
- **Sicher:** Schlüssel mit Berechtigungen, IP- und Origin-Freigaben, signierte Webhooks, Hosting in Deutschland.
- **Teil von Persolz Pro:** Die API ist im Pro-Tarif enthalten – mit 120 Anfragen pro Minute je Schlüssel.

Alle Pfade in dieser Doku beziehen sich auf die **Basis-URL** `https://persolz.com/api/v1`. Den API-Index und die maschinenlesbare OpenAPI-Beschreibung erreichst Du ohne Schlüssel.

**Basis-URL**

```text
https://persolz.com/api/v1
```

**API-Index (ohne Schlüssel)**

*cURL*

```bash
curl https://persolz.com/api/v1
```

*PHP*

```php
<?php
$ch = curl_init('https://persolz.com/api/v1');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1');
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1",
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### Aufbau der Antworten

Erfolgreiche Antworten haben immer denselben Umschlag: Die eigentlichen Daten stehen in **`data`**, Zusatzinfos wie die Request-ID und die Seiteninfos in **`meta`**. Fehler kommen stattdessen als **`error`**-Objekt (siehe [Fehler](#fehler)).

- **Statuscodes:** `200` OK, `201` angelegt, `202` angenommen (Versand läuft), `204` erledigt ohne Inhalt (z. B. nach dem Löschen).
- **Zeitangaben:** ISO 8601 mit Zeitzone Europe/Berlin, z. B. `2026-10-09T14:05:00+02:00`. Eingaben dürfen auch `2026-10-09 14:05` oder `2026-10-09T14:05` sein.
- **IDs** sind ganze Zahlen, Quoten (`open_rate` …) Werte zwischen 0 und 1.
- **Ignoriere unbekannte Felder:** Neue Felder können jederzeit dazukommen – Dein Code sollte damit klarkommen.

**Erfolg**

```json
{
  "data": ["…"],
  "meta": {
    "request_id": "req_…",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 120,
      "pages": 5
    }
  }
}
```

**Fehler**

```json
{
  "error": {
    "code": "validation_error",
    "message": "Bitte gib eine E-Mail-Adresse an.",
    "fields": {
      "email": "Bitte gib eine E-Mail-Adresse an."
    },
    "request_id": "req_…"
  }
}
```

<a id="schnellstart"></a>
## Schnellstart in 5 Minuten

Vom Schlüssel zum ersten versendeten Newsletter – Schritt für Schritt. Zu jedem Schritt gibt es fertigen Code in cURL, PHP, JavaScript und Python.

### 1. API-Schlüssel erstellen

Melde Dich in Persolz an und öffne **API & Doku → [API-Schlüssel](https://persolz.com/developers#schluessel)**. Klicke auf **„Neuer API-Schlüssel“**, gib ihm einen sprechenden Namen (z. B. „Shop“ oder „Funnel kartenherz.com“) und wähle die Berechtigungen.

Der Schlüssel beginnt mit `pz_live_` und wird **nur einmal vollständig angezeigt**. Speichere ihn sicher – am besten als Umgebungsvariable `PERSOLZ_API_KEY` auf Deinem Server. In den Beispielen steht an seiner Stelle der Platzhalter `pz_live_DEIN_API_SCHLUESSEL`.

**Schlüssel hinterlegen**

```bash
# Schlüssel als Umgebungsvariable ablegen (nie ins Git-Repository!)
export PERSOLZ_API_KEY="pz_live_DEIN_API_SCHLUESSEL"

# Verbindung testen – ohne Schlüssel kommt 401
curl https://persolz.com/api/v1/me -H "Authorization: Bearer $PERSOLZ_API_KEY"
```

### 2. Verbindung testen

Rufe `GET /me` auf. Die Antwort zeigt Dein Konto, Deinen Tarif, die Limits und den verwendeten Schlüssel. Bekommst Du `401`, fehlt der Header oder der Schlüssel stimmt nicht; `402` heißt: Die API ist in Deinem Tarif nicht enthalten.

**Konto abrufen**

*cURL*

```bash
curl https://persolz.com/api/v1/me \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/me');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/me', {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/me",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 3. Liste finden und ersten Kontakt anlegen

Mit `GET /lists` siehst Du Deine Listen samt ID. Dann legst Du mit `POST /contacts` einen Kontakt an – die Adresse ist der eindeutige Schlüssel: Gibt es sie schon, wird der Kontakt **aktualisiert** (Upsert, Status `200` statt `201`). Tags werden ergänzt, Listen hinzugefügt.

Ist die Liste auf **Double-Opt-in** gestellt, bekommt der Kontakt automatisch eine Bestätigungsmail und bleibt bis zur Bestätigung `pending`.

**Listen abrufen**

*cURL*

```bash
curl https://persolz.com/api/v1/lists \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/lists');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/lists', {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/lists",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Kontakt anlegen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/contacts \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "list_ids": [3],
    "tags": ["api-test"]
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/contacts');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'email' => 'anna@beispiel.de',
        'first_name' => 'Anna',
        'list_ids' => [3],
        'tags' => ['api-test'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/contacts', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    email: 'anna@beispiel.de',
    first_name: 'Anna',
    list_ids: [3],
    tags: ['api-test']
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/contacts",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "email": "anna@beispiel.de",
        "first_name": "Anna",
        "list_ids": [3],
        "tags": ["api-test"],
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 4. Newsletter als Entwurf anlegen

Ein Newsletter entsteht immer zuerst als **Entwurf** – es wird noch nichts versendet. Inhalt kommt als `html`, als `text` oder aus einer Vorlage (`template_key`). Platzhalter wie `{{vorname|Freund}}` werden später je Empfänger ersetzt. Merke Dir die `id` aus der Antwort.

**Entwurf anlegen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/newsletters \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "API-Test",
    "subject": "Hallo {{vorname|Freund}}, das ist mein erster API-Newsletter",
    "html": "<p>Hallo {{vorname|Freund}},</p><p>dieser Newsletter kommt direkt aus meinem Code.</p>",
    "list_ids": [3]
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/newsletters');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'name' => 'API-Test',
        'subject' => 'Hallo {{vorname|Freund}}, das ist mein erster API-Newsletter',
        'html' => '<p>Hallo {{vorname|Freund}},</p><p>dieser Newsletter kommt direkt aus meinem Code.</p>',
        'list_ids' => [3],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/newsletters', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'API-Test',
    subject: 'Hallo {{vorname|Freund}}, das ist mein erster API-Newsletter',
    html: '<p>Hallo {{vorname|Freund}},</p><p>dieser Newsletter kommt direkt aus meinem Code.</p>',
    list_ids: [3]
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/newsletters",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "name": "API-Test",
        "subject": "Hallo {{vorname|Freund}}, das ist mein erster API-Newsletter",
        "html": "<p>Hallo {{vorname|Freund}},</p><p>dieser Newsletter kommt direkt aus meinem Code.</p>",
        "list_ids": [3],
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 5. Testmail an Dich senden

Prüfe den Newsletter, bevor er rausgeht: `POST /newsletters/{id}/test` schickt ihn mit „[TEST]“ im Betreff an bis zu fünf Adressen – ohne Tracking, personalisiert mit einem Beispielkontakt.

**Testmail**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/newsletters/812/test \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "du@beispiel.de"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/newsletters/812/test');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to' => 'du@beispiel.de',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/newsletters/812/test', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    to: 'du@beispiel.de'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/newsletters/812/test",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "to": "du@beispiel.de",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 6. Newsletter senden

Alles gut? Dann löst `POST /newsletters/{id}/send` den Versand aus. Persolz prüft Betreff, Inhalt, Empfänger und SMTP-Konto, verschickt einen ersten Schwung sofort und den Rest automatisch im Hintergrund. Die Antwort kommt mit `202 Accepted`.

Lieber später? `POST /newsletters/{id}/schedule` mit `scheduled_at` plant den Versand. **Geschafft!** Als Nächstes lohnt sich ein Blick auf [Webhooks](#webhooks) und die [Funnel-Integration](#funnel).

**Jetzt senden**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/newsletters/812/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/newsletters/812/send');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/newsletters/812/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Idempotency-Key': crypto.randomUUID()
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/newsletters/812/send",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Antwort**

```json
{
  "data": {
    "id": 812,
    "status": "sending",
    "queued": 1250,
    "sent": 150,
    "failed": 0,
    "message": "Versand gestartet: 150 von 1.250 E-Mails sind raus, der Rest folgt automatisch."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

<a id="authentifizierung"></a>
## Authentifizierung

Jede Anfrage (außer den öffentlichen Endpunkten) braucht einen API-Schlüssel. Schlüssel erstellst, erneuerst und widerrufst Du unter [API & Doku](https://persolz.com/developers#schluessel).

### Schlüssel mitsenden

Sende den Schlüssel am besten als **Bearer-Token** im Header `Authorization`. Alternativ funktioniert der Header `X-API-Key`. Der Query-Parameter `?api_key=` ist nur eine Notlösung, denn URLs landen in Server-Logs und im Browserverlauf (Persolz maskiert ihn im API-Protokoll).

| Variante | Beispiel |
|---|---|
| Bearer (empfohlen) | `Authorization: Bearer pz_live_…` |
| Eigener Header | `X-API-Key: pz_live_…` |
| Query (nicht empfohlen) | `?api_key=pz_live_…` |

Ein Schlüssel besteht aus `pz_live_` und 40 Zeichen (A–Z, a–z, 0–9). Fehlt er, ist er falsch oder widerrufen, antwortet die API mit `401 unauthorized`.

**Drei Wege**

```bash
# Empfohlen: Bearer-Token im Authorization-Header
curl https://persolz.com/api/v1/me \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"

# Alternative: eigener Header
curl https://persolz.com/api/v1/me \
  -H "X-API-Key: pz_live_DEIN_API_SCHLUESSEL"

# Nur im Notfall: als Query-Parameter (landet in Logs und im Verlauf)
curl "https://persolz.com/api/v1/me?api_key=pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort ohne gültigen Schlüssel (401)**

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Kein API-Schlüssel gefunden. Sende ihn im Header „Authorization: Bearer pz_live_…“ oder „X-API-Key“.",
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

### Berechtigungen (Scopes)

Gib jedem Schlüssel nur die Rechte, die er braucht. Ein Shop, der nur Kontakte einträgt, braucht `write` – aber kein `send`. Neue Schlüssel haben standardmäßig alle Rechte (`*`). Bei jedem Endpunkt in der Referenz steht, welcher Scope nötig ist. Fehlt er, kommt `403 forbidden` mit `reason: "insufficient_scope"`.

| Scope | Name | Erlaubt |
|---|---|---|
| `read` | Lesen | Alle GET-Endpunkte: Kontakte, Listen, Kampagnen, Statistiken … |
| `write` | Schreiben | Anlegen, Ändern und Löschen (POST/PATCH/PUT/DELETE) |
| `send` | Versenden | E-Mails, Newsletter und Seinfeld-Mails verschicken oder planen |
| `ai` | KI | KI-Texte erzeugen (verbraucht Dein KI-Guthaben) |
| `*` | Alle | Voller Zugriff (Standard für neue Schlüssel). |

**Fehlender Scope (403)**

```json
{
  "error": {
    "code": "forbidden",
    "message": "Diesem API-Schlüssel fehlt die Berechtigung „Versenden“ (Scope „send“).",
    "reason": "insufficient_scope",
    "required_scope": "send",
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

### IP-Adressen und Origins freigeben

Zusätzlich zu den Scopes kannst Du jeden Schlüssel einschränken:

- **Erlaubte IP-Adressen:** einzelne Adressen (`203.0.113.7`) oder Bereiche (`203.0.113.0/24`). Ideal für Server mit fester IP – ein gestohlener Schlüssel nützt dann niemandem.
- **Erlaubte Origins:** Websites, die den Schlüssel im Browser nutzen dürfen, z. B. `https://kartenherz.com` oder mit Platzhalter für Subdomains `https://*.kartenherz.com`. Mehr dazu unter [CORS & Browser](#cors).

Anfragen von anderswo werden mit `403 forbidden` abgelehnt (`reason`: `ip_not_allowed` bzw. `origin_not_allowed`).

### Schlüssel sicher aufbewahren

- **Nie in öffentlichen Code:** nicht ins HTML Deiner Website, nicht in eine App, nicht ins Git-Repository. Nutze Umgebungsvariablen oder einen Secret-Speicher.
- **Ein Schlüssel pro Anwendung:** So siehst Du im [API-Protokoll](#ref-logs), wer was macht, und kannst gezielt sperren.
- **Erneuern statt teilen:** „Erneuern“ erzeugt sofort einen neuen Schlüssel mit denselben Einstellungen – der alte ist ab dann ungültig.
- **Verdacht auf Missbrauch?** Widerrufe den Schlüssel sofort unter API & Doku. Jede Anfrage wird mit Zeit, IP, Herkunft und Ergebnis protokolliert (90 Tage).

<a id="fehler"></a>
## Fehler

Persolz nutzt HTTP-Statuscodes: `2xx` = Erfolg, `4xx` = Problem mit der Anfrage (Eingaben, Rechte, Limits), `5xx` = Problem bei Persolz. Jede Fehlerantwort enthält ein `error`-Objekt mit festem Code und deutscher Meldung.

### Das Fehler-Objekt

| Feld | Bedeutung |
|---|---|
| `error.code` | Fester, maschinenlesbarer Code – darauf reagiert Dein Programm. |
| `error.message` | Verständliche Meldung auf Deutsch – kannst Du direkt anzeigen. |
| `error.fields` | Nur bei `422`: Fehler je Eingabefeld, z. B. `{"email": "…"}`. |
| `error.request_id` | ID der Anfrage (auch im Header `X-Request-Id`). Gib sie bei Support-Anfragen an. |
| weitere Felder | Je nach Fall z. B. `reason`, `retry_after`, `upgrade_url`, `feature`. |

Werte immer zuerst den **Statuscode** und `error.code` aus – die Meldungstexte können sich verbessern.

**Ungültige Eingabe (422)**

```json
{
  "error": {
    "code": "validation_error",
    "message": "Bitte gib eine E-Mail-Adresse an.",
    "fields": {
      "email": "Bitte gib eine E-Mail-Adresse an."
    },
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

**Limit des Tarifs erreicht (402)**

```json
{
  "error": {
    "code": "limit_reached",
    "message": "Du hast das Kontaktlimit Deines Tarifs erreicht (250). Mit Persolz Pro sind es unbegrenzt viele.",
    "upgrade_url": "https://persolz.com/billing",
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

### Alle Fehlercodes

Wie Du mit Fehlern sauber umgehst – inklusive fertiger Wiederhol-Logik – zeigt die Anleitung [Fehlerbehandlung & Wiederholungen](#wiederholungen).

| HTTP | Code | Bedeutung | Was tun? |
|---|---|---|---|
| 400 | `bad_request` | Die Anfrage ist formal ungültig (z. B. kaputtes JSON). | JSON prüfen (gültig? UTF-8?) und Content-Type: application/json senden. |
| 401 | `unauthorized` | Kein, ein ungültiger oder ein widerrufener API-Schlüssel. | Header Authorization: Bearer pz_live_… prüfen; Schlüssel ggf. neu erstellen. |
| 402 | `plan_required` | Die Funktion (oder die API selbst) ist im aktuellen Tarif nicht enthalten. | Funktion im Tarif freischalten (upgrade_url in der Antwort). |
| 402 | `limit_reached` | Ein Limit des Tarifs ist erreicht (z. B. Kontakte, Listen, Tageslimit, Speicher). | Tariflimit erhöhen oder Platz schaffen; Stand mit GET /plan/limits. |
| 403 | `forbidden` | Fehlende Berechtigung (Scope), nicht erlaubte IP-Adresse oder Herkunft, gesperrtes Konto. | reason auswerten: Scope, IP oder Origin beim Schlüssel freigeben. |
| 404 | `not_found` | Die Ressource existiert nicht oder gehört nicht zu Deinem Konto. | ID prüfen – Ressourcen anderer Konten sind nicht sichtbar. |
| 405 | `method_not_allowed` | Die HTTP-Methode ist für diesen Pfad nicht erlaubt (siehe Header „Allow“). | HTTP-Methode prüfen; erlaubte Methoden stehen im Header Allow. |
| 409 | `conflict` | Konflikt mit dem aktuellen Zustand (z. B. bereits vorhanden, bereits versendet, Idempotency-Key wiederverwendet). | Aktuellen Zustand abfragen; bei Idempotency-Konflikt neuen Key verwenden. |
| 413 | `payload_too_large` | Die Anfrage oder Datei ist zu groß. | Anfrage aufteilen (max. 1.000 Einträge) bzw. Datei verkleinern (max. 10 MB). |
| 422 | `validation_error` | Eingaben sind ungültig. Details stehen je Feld in „error.fields“. | error.fields auswerten und Eingaben korrigieren. |
| 429 | `rate_limited` | Zu viele Anfragen. Warte die Sekunden aus „Retry-After“ ab. | Retry-After abwarten, dann erneut senden. |
| 500 | `server_error` | Unerwarteter Fehler. Bitte mit der Request-ID an den Support wenden. | Mit Pause wiederholen; bleibt es dabei, Request-ID an den Support. |
| 503 | `service_unavailable` | Ein abhängiger Dienst (z. B. KI-Anbieter) ist gerade nicht erreichbar. | Mit Pause wiederholen (z. B. KI-Anbieter kurz nicht erreichbar). |

<a id="rate-limits"></a>
## Rate-Limits

Damit die API für alle schnell bleibt, gilt ein Limit von **120 Anfragen pro Minute je API-Schlüssel**. Brauchst Du mehr Durchsatz, verteile die Last auf mehrere Schlüssel – oder nutze die Massen-Endpunkte (z. B. `POST /contacts/bulk`). Für einzelne Schlüssel kannst Du im Entwickler-Portal ein **niedrigeres eigenes Limit** setzen (z. B. für ein Skript, das nie mehr als 30 Anfragen pro Minute braucht); `X-RateLimit-Limit` zeigt immer das Limit des verwendeten Schlüssels.

### Header in jeder Antwort

Jede authentifizierte Antwort sagt Dir, wie viel Luft Du noch hast. Ist das Limit erreicht, antwortet die API mit `429 rate_limited` und dem Header `Retry-After` (Sekunden). Warte diese Zeit ab und versuche es dann erneut – nicht sofort in einer Schleife.

| Header | Bedeutung |
|---|---|
| `X-RateLimit-Limit` | Erlaubte Anfragen pro Minute für diesen Schlüssel. |
| `X-RateLimit-Remaining` | Verbleibende Anfragen im aktuellen Fenster. |
| `X-RateLimit-Reset` | Unix-Zeitstempel, zu dem das Fenster zurückgesetzt wird. |
| `Retry-After` | Nur bei 429: Sekunden bis zum nächsten Versuch. |

**Antwort-Header**

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: req_2b9f1c7d0e3a4b5c6d7e
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1791560735
```

**Limit erreicht (429)**

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Zu viele Anfragen: Dieser API-Schlüssel darf 120 Anfragen pro Minute stellen. Bitte warte 23 Sekunden.",
    "retry_after": 23,
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

### Weitere Grenzen

| Bereich | Grenze |
|---|---|
| Öffentliche Anmeldung (`/subscribe/{token}`) | 10 Anmeldungen pro Minute je IP, 200 pro Tag je Liste |
| KI-Texte (`POST /ai/generate`) | 30 Anfragen pro Minute; alle KI-Endpunkte verbrauchen KI-Guthaben |
| Massenabgleich & Listen-Import | max. 1.000 Kontakte pro Anfrage |
| Seitengröße (`per_page`) | max. 100 Einträge |
| Anhänge | max. 10 MB je Datei, Speicher laut Tarif |
| E-Mail-Inhalt (`html`) | max. 2 MB |
| Webhooks / API-Schlüssel | max. 20 Webhooks, 25 aktive Schlüssel |

Dazu kommen die **Limits Deines Tarifs** (Kontakte, Listen, Versände pro Tag …). Wird eines erreicht, antwortet die API mit `402 limit_reached`. Deinen aktuellen Stand zeigt `GET /plan/limits`.

<a id="paginierung"></a>
## Paginierung

Listen-Endpunkte (z. B. `GET /contacts`) liefern ihre Ergebnisse seitenweise.

### Seiten abrufen

| Parameter | Bedeutung |
|---|---|
| `page` | Seite, ab 1 (Standard 1) |
| `per_page` | Einträge pro Seite, 1–100 (Standard 25) |

Die Antwort enthält in `meta.pagination` die aktuelle Seite, die Seitengröße, die Gesamtzahl (`total`) und die Anzahl Seiten (`pages`). Frage so lange die nächste Seite ab, bis `page` gleich `pages` ist.

**Tipp für Synchronisationen:** Statt immer alles zu laden, holst Du mit `updated_after=<Zeitpunkt des letzten Abgleichs>&sort=updated_asc` nur geänderte Kontakte.

**Alle Seiten laden**

*PHP*

```php
<?php
// Alle Kontakte einer Liste seitenweise abrufen
$apiKey = getenv('PERSOLZ_API_KEY');
$page = 1;
do {
    $query = http_build_query(['list_id' => 3, 'per_page' => 100, 'page' => $page]);
    $ch = curl_init('https://persolz.com/api/v1/contacts?' . $query);
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
    ]);
    $json = json_decode((string) curl_exec($ch), true);
    curl_close($ch);

    foreach ($json['data'] as $contact) {
        echo $contact['email'], PHP_EOL;
    }
    $pages = $json['meta']['pagination']['pages'];
    $page++;
} while ($page <= $pages);
```

*JavaScript*

```javascript
// Alle Kontakte einer Liste seitenweise abrufen (Node.js 18+)
const headers = { 'Authorization': `Bearer ${process.env.PERSOLZ_API_KEY}` };
let page = 1;
let pages = 1;
do {
  const params = new URLSearchParams({ list_id: '3', per_page: '100', page: String(page) });
  const response = await fetch('https://persolz.com/api/v1/contacts?' + params, { headers });
  const json = await response.json();
  json.data.forEach((contact) => console.log(contact.email));
  pages = json.meta.pagination.pages;
  page++;
} while (page <= pages);
```

*Python*

```python
import os
import requests

headers = {"Authorization": f"Bearer {os.environ['PERSOLZ_API_KEY']}"}
page, pages = 1, 1
while page <= pages:
    result = requests.get(
        "https://persolz.com/api/v1/contacts",
        headers=headers,
        params={"list_id": 3, "per_page": 100, "page": page},
        timeout=30,
    ).json()
    for contact in result["data"]:
        print(contact["email"])
    pages = result["meta"]["pagination"]["pages"]
    page += 1
```

**Antwort (gekürzt)**

```json
{
  "data": [
    {
      "id": 42,
      "email": "anna@beispiel.de",
      "…": "…"
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "per_page": 100,
      "total": 1250,
      "pages": 13
    },
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

<a id="idempotenz"></a>
## Idempotenz

Netzwerke sind unzuverlässig: Eine Anfrage kommt an, aber die Antwort geht verloren. Mit einem **Idempotency-Key** kannst Du POST-Anfragen gefahrlos wiederholen – ohne doppelte Kontakte oder doppelt versendete Mails.

### So funktioniert es

1. Erzeuge pro Vorgang einen eindeutigen Wert (z. B. eine UUID oder `bestellung-4711-bestaetigung`) und sende ihn im Header `Idempotency-Key` mit.
2. Wiederholst Du die Anfrage innerhalb von **24 Stunden** mit demselben Schlüssel, bekommst Du die gespeicherte Antwort zurück – mit dem Header `Idempotent-Replayed: true`. Es wird nichts doppelt angelegt oder versendet.
3. Derselbe Schlüssel mit **anderem Inhalt** ergibt `409 conflict` (`reason: "idempotency_key_reused"`). Verglichen wird der exakte Body – sende eine Wiederholung also byte-gleich (denselben JSON-String, nicht neu formatiert). Läuft die erste Anfrage noch, kommt ebenfalls `409` mit `Retry-After: 2`.

Idempotency-Keys funktionieren für alle `POST`-Endpunkte (außer den öffentlichen). Besonders wichtig sind sie beim **Versenden** und **Anlegen**. Server-Fehler (`5xx`) werden nicht gespeichert – ein neuer Versuch wird also wirklich neu ausgeführt.

**E-Mail mit Idempotency-Key senden**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/emails/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: bestellung-4711-bestaetigung" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "anna@beispiel.de",
    "subject": "Deine Bestellung #4711",
    "html": "<p>Danke für Deine Bestellung!</p>"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/emails/send');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: bestellung-4711-bestaetigung',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to' => 'anna@beispiel.de',
        'subject' => 'Deine Bestellung #4711',
        'html' => '<p>Danke für Deine Bestellung!</p>',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/emails/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'bestellung-4711-bestaetigung'
  },
  body: JSON.stringify({
    to: 'anna@beispiel.de',
    subject: 'Deine Bestellung #4711',
    html: '<p>Danke für Deine Bestellung!</p>'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/emails/send",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": "bestellung-4711-bestaetigung",
    },
    json={
        "to": "anna@beispiel.de",
        "subject": "Deine Bestellung #4711",
        "html": "<p>Danke für Deine Bestellung!</p>",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Wiederholung**

```http
# Zweiter Aufruf mit demselben Idempotency-Key:
HTTP/1.1 202 Accepted
Idempotent-Replayed: true
Content-Type: application/json; charset=utf-8

{"data":{"campaign_id":813,"status":"sending", …}}
```

<a id="cors"></a>
## CORS & Browser

Die API lässt sich auch direkt aus dem Browser aufrufen. Dafür gelten besondere Regeln – vor allem: **Ein Schlüssel im Browser ist für jeden sichtbar.**

### Wann ist der Browser okay?

> **Goldene Regel:** Lege niemals einen Schlüssel mit vollen Rechten in eine öffentliche Seite. Jeder Besucher kann ihn im Quelltext lesen.

| Du willst … | Dann nutze … |
|---|---|
| ein Anmeldeformular auf Deiner Website | den **öffentlichen Anmelde-Endpunkt** `POST /subscribe/{token}` – ganz ohne Schlüssel ([Anleitung](#funnel-html)) |
| Kontakte mit Seifenoper, Tags und eigenen Feldern eintragen | Deinen **Server** (PHP, Node …) – der Schlüssel bleibt geheim ([Anleitung](#funnel-server)) |
| die API in einem geschützten Bereich nutzen (Mitgliederbereich, internes Dashboard) | einen **eingeschränkten Schlüssel** mit erlaubter Origin und minimalem Scope ([Anleitung](#funnel-js)) |

Die Origin-Freigabe sorgt dafür, dass fremde Websites Deinen Schlüssel nicht im Browser ihrer Besucher verwenden können. **Sie macht den Schlüssel aber nicht geheim:** Außerhalb des Browsers (z. B. mit cURL) lässt sich der Origin-Header beliebig setzen. Gib Browser-Schlüsseln deshalb nur Rechte, deren Missbrauch keinen großen Schaden anrichtet – und erneuere sie regelmäßig.

### Technische Details

- **Erlaubte Origins** trägst Du beim Schlüssel ein, z. B. `https://kartenherz.com` und `https://*.kartenherz.com` (alle Subdomains). Ohne Eintrag sind alle Herkünfte erlaubt.
- **Preflight:** Browser fragen vor Anfragen mit `Authorization`-Header per `OPTIONS` nach. Persolz antwortet mit `204`, `Access-Control-Max-Age: 600` (10 Minuten Cache) und `Vary: Origin`.
- **Lesbare Header:** `X-Request-Id`, `X-RateLimit-*`, `Retry-After` und `Idempotent-Replayed` sind für JavaScript freigegeben.
- Eine nicht freigegebene Herkunft bekommt `403 forbidden` mit `reason: "origin_not_allowed"`.
- Der öffentliche Anmelde-Endpunkt antwortet immer mit `Access-Control-Allow-Origin: *`.

**Preflight prüfen**

```bash
# So prüft der Browser vor einer Anfrage, ob er darf (Preflight)
curl -i -X OPTIONS https://persolz.com/api/v1/contacts \
  -H "Origin: https://kartenherz.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type"

# Antwort (gekürzt)
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://kartenherz.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-API-Key, Idempotency-Key, X-Requested-With
Access-Control-Max-Age: 600
Vary: Origin
```

<a id="platzhalter"></a>
## Platzhalter

Mit Platzhaltern personalisierst Du Betreff, Vorschautext, Inhalt und sogar Links – für jeden Empfänger einzeln. Sie funktionieren in E-Mails, Newslettern, Seinfeld-Mails und Seifenopern.

### Verfügbare Platzhalter

Schreibe Platzhalter in doppelte geschweifte Klammern, z. B. `{{vorname}}`. Groß-/Kleinschreibung und Leerzeichen sind egal (`{{ Vorname }}` geht auch). Zu jedem deutschen Platzhalter gibt es einen englischen Alias.

- **Ersatzwert:** `{{vorname|Freund}}` – ist der Vorname leer, steht dort „Freund“.
- **Eigene Felder:** Jeder Schlüssel aus `custom_fields` eines Kontakts ist ein Platzhalter, z. B. `{{kundennummer}}`.
- **Abmeldelink:** `{{abmelde_link}}` setzt den persönlichen Abmeldelink ein. Fehlt er, ergänzt Persolz automatisch eine Abmeldezeile.

| Platzhalter | Englischer Alias | Bedeutung | Beispiel |
|---|---|---|---|
| `{{vorname}}` | `{{first_name}}` | Vorname | Max |
| `{{nachname}}` | `{{last_name}}` | Nachname | Mustermann |
| `{{name}}` | `{{full_name}}` | Vollständiger Name | Max Mustermann |
| `{{email}}` | `{{email}}` | E-Mail-Adresse | max.mustermann@example.com |
| `{{adresse}}` | `{{address}}` | Adresse (Straße & Hausnummer) | Musterstraße 12 |
| `{{plz}}` | `{{zip}}` | PLZ | 10115 |
| `{{ort}}` | `{{city}}` | Ort | Berlin |
| `{{telefon}}` | `{{phone}}` | Telefon | +49 30 1234567 |
| `{{notiz}}` | `{{note}}` | Notiz | Beispielkontakt für die Vorschau |
| `{{datum}}` | `{{date}}` | Heutiges Datum | 15.10.2026 |
| `{{abmelde_link}}` | `{{unsubscribe_url}}` | Abmeldelink | https://persolz.com/u/… |

**Beispiel**

```text
Betreff:  Deine Bestellung #{{bestellnummer}}, {{vorname|Freund}}
HTML:     <p>Hallo {{vorname|Freund}},</p>
          <p>Dein Kartenset ist unterwegs nach {{ort}}.</p>
          <p><a href="https://kartenherz.com/konto?kd={{kundennummer}}">Zum Kundenkonto</a></p>

→ an Anna Schmidt (Berlin, custom_fields.kundennummer = K-1001), placeholders = {"bestellnummer": "4711"}

Betreff:  Deine Bestellung #4711, Anna
HTML:     <p>Hallo Anna,</p>
          <p>Dein Kartenset ist unterwegs nach Berlin.</p>
          <p><a href="https://kartenherz.com/konto?kd=K-1001">Zum Kundenkonto</a></p>
```

### Eigene Platzhalter per API

Bei `POST /emails/send` kannst Du zusätzlich ein Objekt **`placeholders`** mitsenden, z. B. `{"bestellnummer": "4711"}` – dann wird `{{bestellnummer}}` für **alle Empfänger dieser Anfrage** gleich ersetzt. Brauchst Du je Empfänger andere Werte (Bestellnummer, Liefertermin), sende **eine Anfrage pro Empfänger** – siehe [Transaktionale E-Mails](#transaktional).

Gut zu wissen:
- Namen: `a–z`, `0–9`, `_` und `-`, max. 50 eigene Platzhalter pro Anfrage. Eingebaute Namen (`vorname`, `email` …) werden immer je Empfänger befüllt und lassen sich nicht überschreiben.
- Werte werden im HTML automatisch maskiert – HTML-Code in Werten erscheint als Text. In Links werden Werte URL-kodiert.
- **Unbekannte Platzhalter bleiben stehen** (z. B. Tippfehler wie `{{vorame}}`). Schicke vor großen Versänden eine Testmail.

**E-Mail mit eigenem Platzhalter**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/emails/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["anna@beispiel.de"],
    "subject": "Deine Bestellung #{{bestellnummer}}",
    "html": "<p>Hallo {{vorname|Freund}},</p><p>Deine Bestellung {{bestellnummer}} ist unterwegs.</p>",
    "placeholders": {
      "bestellnummer": "4711"
    }
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/emails/send');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to' => ['anna@beispiel.de'],
        'subject' => 'Deine Bestellung #{{bestellnummer}}',
        'html' => '<p>Hallo {{vorname|Freund}},</p><p>Deine Bestellung {{bestellnummer}} ist unterwegs.</p>',
        'placeholders' => [
            'bestellnummer' => '4711',
        ],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/emails/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    to: ['anna@beispiel.de'],
    subject: 'Deine Bestellung #{{bestellnummer}}',
    html: '<p>Hallo {{vorname|Freund}},</p><p>Deine Bestellung {{bestellnummer}} ist unterwegs.</p>',
    placeholders: {
      bestellnummer: '4711'
    }
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/emails/send",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "to": ["anna@beispiel.de"],
        "subject": "Deine Bestellung #{{bestellnummer}}",
        "html": "<p>Hallo {{vorname|Freund}},</p><p>Deine Bestellung {{bestellnummer}} ist unterwegs.</p>",
        "placeholders": {
            "bestellnummer": "4711",
        },
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

<a id="webhooks"></a>
## Webhooks

Statt ständig nachzufragen, ob sich etwas getan hat, schickt Persolz Deinem System eine Nachricht, sobald etwas passiert: neuer Kontakt, Abmeldung, Öffnung, Klick, abgeschlossener Versand …

### So funktionieren Webhooks

1. Lege unter **[API & Doku → Webhooks](https://persolz.com/developers#webhooks)** oder per `POST /webhooks` einen Webhook an: die öffentlich erreichbare **URL** Deines Endpunkts (am besten `https://`) und die gewünschten Ereignisse (oder `["*"]` für alle). Du bekommst ein **Signatur-Secret** (`whsec_…`).
2. Bei jedem Ereignis sendet Persolz einen `POST` mit JSON an Deine URL: `{"id", "event", "created_at", "data"}`.
3. Dein Endpunkt antwortet innerhalb von **4 Sekunden** mit einem `2xx`-Status. Alles andere gilt als Fehlschlag.
4. Fehlschläge werden nach **1, 5, 30, 120 und 720 Minuten** wiederholt (max. 6 Versuche). Nach **20 Fehlschlägen in Folge** wird der Webhook pausiert und Du bekommst eine Benachrichtigung.

Alle Zustellungen (mit Payload, Antwortcode und Fehler) siehst Du 30 Tage lang im Zustellprotokoll – dort kannst Du sie auch erneut senden. Webhook-URLs müssen öffentlich erreichbar sein; interne Adressen (localhost, private Netze) werden aus Sicherheitsgründen nicht beliefert.

| Header | Inhalt |
|---|---|
| `X-Persolz-Event` | Name des Ereignisses, z. B. contact.created |
| `X-Persolz-Delivery` | ID der Zustellung (bei Wiederholungen gleich) |
| `X-Persolz-Event-Id` | ID des Ereignisses (evt_…) – nutze sie zum Entdoppeln |
| `X-Persolz-Signature` | t=<Unix-Zeit>,v1=<HMAC-SHA256 als Hex> |

**Webhook anlegen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/webhooks \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://kartenherz.com/hooks/persolz",
    "events": ["contact.created", "contact.unsubscribed", "email.clicked"],
    "description": "Shop-Synchronisation"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/webhooks');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'url' => 'https://kartenherz.com/hooks/persolz',
        'events' => ['contact.created', 'contact.unsubscribed', 'email.clicked'],
        'description' => 'Shop-Synchronisation',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/webhooks', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    url: 'https://kartenherz.com/hooks/persolz',
    events: ['contact.created', 'contact.unsubscribed', 'email.clicked'],
    description: 'Shop-Synchronisation'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/webhooks",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "url": "https://kartenherz.com/hooks/persolz",
        "events": ["contact.created", "contact.unsubscribed", "email.clicked"],
        "description": "Shop-Synchronisation",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**So kommt ein Ereignis bei Dir an**

```http
POST /hooks/persolz HTTP/1.1
Host: kartenherz.com
Content-Type: application/json; charset=utf-8
User-Agent: Persolz-Webhooks/1.0
X-Persolz-Event: contact.created
X-Persolz-Delivery: 9001
X-Persolz-Event-Id: evt_3f9a1c2b4d5e6f708192a3b4
X-Persolz-Signature: t=1791560735,v1=5f3a8c1e9b2d7f6a4c0e8b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a

{"id":"evt_3f9a1c2b4d5e6f708192a3b4","event":"contact.created","created_at":"2026-10-09T14:05:00+02:00","data":{"contact":{"id":42,"email":"anna@beispiel.de", …}}}
```

<a id="webhooks-ereignisse"></a>
### Ereignisse

Diese Ereignisse kannst Du abonnieren. Jedes kommt im gleichen Umschlag; was in `data` steht, zeigt das Beispiel des jeweiligen Ereignisses. `email.opened` kommt nur bei der **ersten** Öffnung, `email.clicked` beim ersten Klick **je Link** – Bots werden herausgefiltert. Die **Reihenfolge ist nicht garantiert** (bei neuen Kontakten kommt z. B. `list.joined` oft vor `contact.created`).

#### `contact.created` – Kontakt angelegt

Ein neuer Kontakt wurde angelegt (App, Import, API, Formular, Integration).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "contact.created",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "contact": {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    }
  }
}
```

#### `contact.updated` – Kontakt geändert

Daten, Status oder Listen eines Kontakts haben sich geändert (auch nach Double-Opt-in-Bestätigung).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |
| `data.changed` | `array<string>` | Namen der geänderten Felder. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "contact.updated",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "contact": {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    },
    "changed": ["first_name", "lists"]
  }
}
```

#### `contact.deleted` – Kontakt gelöscht

Ein Kontakt wurde gelöscht.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "contact.deleted",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "contact": {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    }
  }
}
```

#### `contact.unsubscribed` – Kontakt abgemeldet

Ein Kontakt hat sich abgemeldet (Abmeldelink, Ein-Klick-Abmeldung, manuell oder per API).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |
| `data.email` | `string` | E-Mail-Adresse des Kontakts. |
| `data.source` | `string` | Quelle bzw. Auslöser (bei inbox.replied: user, ai oder rule). |
| `data.reason` | `string · null` | Grund (optional). |
| `data.campaign_id` | `integer` | ID der Kampagne. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "contact.unsubscribed",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "contact": {
      "status": "unsubscribed",
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    },
    "email": "anna@beispiel.de",
    "source": "link",
    "reason": null,
    "campaign_id": 812
  }
}
```

#### `list.joined` – Liste beigetreten

Ein aktiver Kontakt wurde in eine Liste aufgenommen.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.list` | `object` | Die Liste. |
| `data.list.id` | `integer` | ID der Liste. |
| `data.list.name` | `string` | Name der Liste. |
| `data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "list.joined",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "list": {
      "id": 3,
      "name": "Kartenherz Funnel"
    },
    "contact": {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    }
  }
}
```

#### `email.opened` – E-Mail geöffnet

Ein Empfänger hat eine E-Mail zum ersten Mal geöffnet (Bots werden herausgefiltert).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.campaign` | `object` | Die Kampagne der E-Mail. |
| `data.campaign.id` | `integer` | ID der Kampagne. |
| `data.campaign.type` | `string` | Versandart: email, newsletter, seinfeld oder soap. |
| `data.campaign.name` | `string` | Interner Name der Kampagne. |
| `data.campaign.subject` | `string` | Betreff. |
| `data.campaign.ref_id` | `integer · null` | Bei Seinfeld-Mails die ID der Seinfeld-Mail, bei Seifenopern die ID des Tages, sonst null. |
| `data.contact_id` | `integer` | ID des Kontakts. |
| `data.email` | `string` | E-Mail-Adresse des Kontakts. |
| `data.variant` | `string · null` | A/B-Variante (A oder B) oder null. |
| `data.message_id` | `string` | ID der versendeten Nachricht. |
| `data.device` | `string` | Gerät: desktop, mobile oder tablet. |
| `data.implied` | `boolean` | true, wenn die Öffnung aus einem Klick abgeleitet wurde (Bilder waren blockiert). |
| `data.opened_at` | `string (Datum/Zeit)` | Zeitpunkt der ersten Öffnung. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "email.opened",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "campaign": {
      "id": 812,
      "type": "newsletter",
      "name": "Herbst-Aktion",
      "subject": "Anna, das hier ist nur für Dich",
      "ref_id": null
    },
    "contact_id": 42,
    "email": "anna@beispiel.de",
    "variant": null,
    "message_id": "6f1c0d9a2b7e4c1f8a3d5e6f7a8b9c0d",
    "device": "mobile",
    "implied": false,
    "opened_at": "2026-10-09T14:05:00+02:00"
  }
}
```

#### `email.clicked` – Link geklickt

Ein Empfänger hat einen Link zum ersten Mal geklickt (je Link, Bots werden herausgefiltert).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.campaign` | `object` | Die Kampagne der E-Mail. |
| `data.campaign.id` | `integer` | ID der Kampagne. |
| `data.campaign.type` | `string` | Versandart: email, newsletter, seinfeld oder soap. |
| `data.campaign.name` | `string` | Interner Name der Kampagne. |
| `data.campaign.subject` | `string` | Betreff. |
| `data.campaign.ref_id` | `integer · null` | Bei Seinfeld-Mails die ID der Seinfeld-Mail, bei Seifenopern die ID des Tages, sonst null. |
| `data.contact_id` | `integer` | ID des Kontakts. |
| `data.email` | `string` | E-Mail-Adresse des Kontakts. |
| `data.variant` | `string` | A/B-Variante (A oder B) oder null. |
| `data.message_id` | `string` | ID der versendeten Nachricht. |
| `data.link` | `object` | Der geklickte Link. |
| `data.link.id` | `integer` | ID des Links. |
| `data.link.url` | `string` | Ziel-URL. |
| `data.link.label` | `string` | Linktext. |
| `data.device` | `string` | Gerät: desktop, mobile oder tablet. |
| `data.clicked_at` | `string (Datum/Zeit)` | Zeitpunkt des ersten Klicks auf diesen Link. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "email.clicked",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "campaign": {
      "id": 812,
      "type": "newsletter",
      "name": "Herbst-Aktion",
      "subject": "Anna, das hier ist nur für Dich",
      "ref_id": null
    },
    "contact_id": 42,
    "email": "anna@beispiel.de",
    "variant": "B",
    "message_id": "6f1c0d9a2b7e4c1f8a3d5e6f7a8b9c0d",
    "link": {
      "id": 77,
      "url": "https://kartenherz.com/angebot",
      "label": "Jetzt ansehen"
    },
    "device": "desktop",
    "clicked_at": "2026-10-09T14:06:12+02:00"
  }
}
```

#### `campaign.sent` – Kampagne versendet

Ein Newsletter, eine E-Mail oder eine Seinfeld-Mail wurde vollständig versendet.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.campaign_id` | `integer` | ID der Kampagne. |
| `data.type` | `string` | Versandart: email, newsletter oder seinfeld. |
| `data.name` | `string` | Interner Name der Kampagne. |
| `data.subject` | `string` | Betreff. |
| `data.status` | `string` | Status der Kampagne nach Abschluss (in der Regel sent). |
| `data.recipients` | `integer` | Empfänger gesamt. |
| `data.sent` | `integer` | Erfolgreich versendet. |
| `data.failed` | `integer` | Fehlgeschlagen. |
| `data.started_at` | `string (Datum/Zeit)` | Beginn des Versands oder null. |
| `data.finished_at` | `string (Datum/Zeit)` | Ende des Versands oder null. |
| `data.ref_id` | `integer · null` | Bei Seinfeld-Mails die ID der Seinfeld-Mail, bei Seifenopern die ID des Tages, sonst null. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "campaign.sent",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "campaign_id": 812,
    "type": "newsletter",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "status": "sent",
    "recipients": 1250,
    "sent": 1248,
    "failed": 2,
    "started_at": "2026-10-09T09:00:00+02:00",
    "finished_at": "2026-10-09T09:04:31+02:00",
    "ref_id": null
  }
}
```

#### `soap.enrolled` – Seifenoper gestartet

Ein Kontakt wurde in eine Seifenoper aufgenommen.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.enrollment_id` | `integer` | ID der Teilnahme an der Seifenoper. |
| `data.sequence_id` | `integer` | ID der Seifenoper. |
| `data.sequence_name` | `string` | Name der Seifenoper. |
| `data.contact_id` | `integer` | ID des Kontakts. |
| `data.email` | `string` | E-Mail-Adresse des Kontakts. |
| `data.source` | `string` | Quelle bzw. Auslöser (bei inbox.replied: user, ai oder rule). |
| `data.start_date` | `string (Datum)` | Startdatum der Teilnahme. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "soap.enrolled",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "enrollment_id": 501,
    "sequence_id": 7,
    "sequence_name": "Willkommen bei Kartenherz",
    "contact_id": 42,
    "email": "anna@beispiel.de",
    "source": "api",
    "start_date": "2026-10-10"
  }
}
```

#### `soap.completed` – Seifenoper abgeschlossen

Ein Kontakt hat alle Mails einer Seifenoper erhalten.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.enrollment_id` | `integer` | ID der Teilnahme an der Seifenoper. |
| `data.sequence_id` | `integer` | ID der Seifenoper. |
| `data.sequence_name` | `string` | Name der Seifenoper. |
| `data.contact_id` | `integer` | ID des Kontakts. |
| `data.email` | `string` | E-Mail-Adresse des Kontakts. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "soap.completed",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "enrollment_id": 501,
    "sequence_id": 7,
    "sequence_name": "Willkommen bei Kartenherz",
    "contact_id": 42,
    "email": "anna@beispiel.de"
  }
}
```

#### `appointment.created` – Termin angelegt

Ein Termin wurde im Terminbuch angelegt.

`data` ist ein [Termin-Objekt](#obj-appointment).

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "appointment.created",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "id": 55,
    "title": "Erstgespräch Anna Schmidt",
    "description": "Kennenlernen per Video",
    "location": "Zoom",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "all_day": false,
    "contact_id": 42,
    "contact": {
      "id": 42,
      "name": "Anna Schmidt",
      "email": "anna@beispiel.de",
      "phone": "+49 30 1234567"
    },
    "attendees": ["anna@beispiel.de"],
    "phone": "",
    "color": "violet",
    "status": "planned",
    "status_label": "Geplant",
    "reminder_minutes": 60,
    "remind_attendees": true,
    "source": "api",
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00"
  }
}
```

#### `appointment.updated` – Termin geändert

Ein Termin wurde geändert, verschoben oder abgesagt.

`data` ist ein [Termin-Objekt](#obj-appointment), zusätzlich:

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.changed` | `array<string>` | Namen der geänderten Felder. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "appointment.updated",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "id": 55,
    "title": "Erstgespräch Anna Schmidt",
    "description": "Kennenlernen per Video",
    "location": "Zoom",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "all_day": false,
    "contact_id": 42,
    "contact": {
      "id": 42,
      "name": "Anna Schmidt",
      "email": "anna@beispiel.de",
      "phone": "+49 30 1234567"
    },
    "attendees": ["anna@beispiel.de"],
    "phone": "",
    "color": "violet",
    "status": "planned",
    "status_label": "Geplant",
    "reminder_minutes": 60,
    "remind_attendees": true,
    "source": "api",
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "changed": ["start_at", "end_at"]
  }
}
```

#### `inbox.received` – Neue Mail im Posteingang

In einem verbundenen Postfach ist eine neue Mail eingegangen (ohne Newsletter, Abwesenheitsnotizen, Unzustellbarkeitsmeldungen und Spam-Verdacht).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.message` | `Mail-Objekt (Posteingang)` | Testnachricht (test.ping) bzw. die Mail im Posteingang (inbox.*). Siehe [Mail-Objekt (Posteingang)](#obj-inbox-message). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "inbox.received",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "message": {
      "id": 9120,
      "mailbox_id": 2,
      "mailbox_email": "hallo@kartenherz.com",
      "folder": "Posteingang",
      "folder_role": "inbox",
      "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
      "message_id": "<CAF-anfrage-4711@mail.beispiel.de>",
      "direction": "in",
      "from": {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      },
      "to": [
        {
          "email": "hallo@kartenherz.com",
          "name": "Kartenherz"
        }
      ],
      "cc": [],
      "subject": "Frage zu Hochzeitskarten",
      "snippet": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
      "received_at": "2026-10-09T14:05:00+02:00",
      "is_seen": false,
      "is_flagged": false,
      "is_answered": false,
      "has_attachments": false,
      "has_calendar": false,
      "category": "lead",
      "category_label": "Leads & Anfragen",
      "priority": "high",
      "sentiment": "positive",
      "intent": "Angebot für 80 Hochzeitskarten",
      "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
      "needs_reply": true,
      "contact_id": 42,
      "campaign_id": null,
      "labels": ["Hochzeit"],
      "snoozed_until": null,
      "follow_up_at": null,
      "replied_at": null
    }
  }
}
```

#### `inbox.replied` – Mail aus dem Posteingang gesendet

Eine Antwort, Weiterleitung oder neue Mail wurde aus dem Posteingang verschickt – von Dir, nach Deiner Freigabe oder vom KI-Autopiloten.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.draft` | `integer` | Der KI-Entwurf (inbox.approval_needed, inbox.auto_replied) – bei inbox.replied nur seine ID (= draft_id). |
| `data.draft_id` | `integer` | ID der gesendeten Mail (GET /inbox/drafts/{id}). |
| `data.mailbox_id` | `integer` | ID des Postfachs. |
| `data.kind` | `string` | reply, reply_all, forward, new oder auto. |
| `data.source` | `string` | Quelle bzw. Auslöser (bei inbox.replied: user, ai oder rule). |
| `data.auto` | `boolean` | true, wenn der KI-Autopilot die Antwort gesendet hat. |
| `data.to` | `array<string>` | Empfänger (To und Cc, E-Mail-Adressen). |
| `data.subject` | `string` | Betreff. |
| `data.message_id` | `string` | ID der versendeten Nachricht. |
| `data.sent_at` | `string (Datum/Zeit)` | Zeitpunkt des Versands oder null. |
| `data.in_reply_to_message` | `integer` | ID der beantworteten Mail (GET /inbox/messages/{id}) oder null. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "inbox.replied",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "draft": 5501,
    "draft_id": 5501,
    "mailbox_id": 2,
    "kind": "reply",
    "source": "ai",
    "auto": false,
    "to": ["anna@beispiel.de"],
    "subject": "Re: Frage zu Hochzeitskarten",
    "message_id": "<6f1c0d9a2b7e4c1f@kartenherz.com>",
    "sent_at": "2026-10-09T14:21:04+02:00",
    "in_reply_to_message": 9120
  }
}
```

#### `inbox.approval_needed` – KI-Entwurf wartet auf Freigabe

Die KI hat eine Antwort entworfen, die Du freigeben musst (Modus „Mit Freigabe“ oder der Autopilot hat eine Sicherheitsprüfung nicht bestanden).

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.draft` | `object` | Der KI-Entwurf (inbox.approval_needed, inbox.auto_replied) – bei inbox.replied nur seine ID (= draft_id). |
| `data.draft.id` | `integer` | Eindeutige ID. |
| `data.draft.mailbox_id` | `integer` | ID des Postfachs. |
| `data.draft.subject` | `string` | Betreff der Antwort. |
| `data.draft.to` | `array<object>` | Empfänger (Liste aus {email, name}). |
| `data.draft.to[].email` | `string` | E-Mail-Adresse. |
| `data.draft.to[].name` | `string` | Name (kann leer sein). |
| `data.draft.ai_confidence` | `integer` | Selbsteinschätzung der KI (0–100) oder null. |
| `data.draft.ai_reason` | `string` | Begründung der KI bzw. warum der Autopilot nicht selbst gesendet hat. |
| `data.draft.ai_flags` | `array<string>` | Risiken: legal, money, medical, threat, prompt_injection, personal_data, unclear. |
| `data.draft.send_at` | `string · null` | Geplanter Versand (Autopilot: nach der Wartezeit) oder null. |
| `data.message` | `Mail-Objekt (Posteingang)` | Testnachricht (test.ping) bzw. die Mail im Posteingang (inbox.*). Siehe [Mail-Objekt (Posteingang)](#obj-inbox-message). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "inbox.approval_needed",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "draft": {
      "id": 5502,
      "mailbox_id": 2,
      "subject": "Re: Rechnung doppelt abgebucht?",
      "to": [
        {
          "email": "ben@beispiel.de",
          "name": "Ben Krüger"
        }
      ],
      "ai_confidence": 64,
      "ai_reason": "Nicht automatisch gesendet:\n- Kategorie „Beschwerde“ beantwortest Du immer selbst.\n- KI-Risiko: Geld/Erstattung.\nKI-Einschätzung: Ben bittet um Erstattung einer doppelten Abbuchung.",
      "ai_flags": ["money"],
      "send_at": null
    },
    "message": {
      "id": 9133,
      "mailbox_id": 2,
      "mailbox_email": "hallo@kartenherz.com",
      "folder": "Posteingang",
      "folder_role": "inbox",
      "thread_key": "8d2e4b6f0a1c3e5d7f9b1a2c4e6d8f0a1b3c5d7e",
      "message_id": "<rechnung-77@beispiel.de>",
      "direction": "in",
      "from": {
        "email": "ben@beispiel.de",
        "name": "Ben Krüger"
      },
      "to": [
        {
          "email": "hallo@kartenherz.com",
          "name": "Kartenherz"
        }
      ],
      "cc": [],
      "subject": "Rechnung doppelt abgebucht?",
      "snippet": "Hallo, mir wurde die Rechnung 2026-1042 zweimal abgebucht. Bitte erstattet mir den Betrag …",
      "received_at": "2026-10-09T14:05:00+02:00",
      "is_seen": false,
      "is_flagged": false,
      "is_answered": false,
      "has_attachments": false,
      "has_calendar": false,
      "category": "complaint",
      "category_label": "Beschwerde",
      "priority": "high",
      "sentiment": "negative",
      "intent": "Erstattung einer doppelten Abbuchung",
      "summary": "Ben wurde die Rechnung doppelt abgebucht und bittet um Erstattung.",
      "needs_reply": true,
      "contact_id": 57,
      "campaign_id": null,
      "labels": [],
      "snoozed_until": null,
      "follow_up_at": null,
      "replied_at": null
    }
  }
}
```

#### `inbox.auto_replied` – Autopilot-Antwort geplant

Der KI-Autopilot hat eine Antwort automatisch eingeplant. Sie geht nach der eingestellten Verzögerung raus und kann bis dahin gestoppt werden.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.draft` | `object` | Der KI-Entwurf (inbox.approval_needed, inbox.auto_replied) – bei inbox.replied nur seine ID (= draft_id). |
| `data.draft.id` | `integer` | Eindeutige ID. |
| `data.draft.mailbox_id` | `integer` | ID des Postfachs. |
| `data.draft.subject` | `string` | Betreff der Antwort. |
| `data.draft.to` | `array<object>` | Empfänger (Liste aus {email, name}). |
| `data.draft.to[].email` | `string` | E-Mail-Adresse. |
| `data.draft.to[].name` | `string` | Name (kann leer sein). |
| `data.draft.ai_confidence` | `integer` | Selbsteinschätzung der KI (0–100) oder null. |
| `data.draft.ai_reason` | `string` | Begründung der KI bzw. warum der Autopilot nicht selbst gesendet hat. |
| `data.draft.ai_flags` | `array` | Risiken: legal, money, medical, threat, prompt_injection, personal_data, unclear. |
| `data.draft.send_at` | `string (Datum/Zeit)` | Geplanter Versand (Autopilot: nach der Wartezeit) oder null. |
| `data.message` | `Mail-Objekt (Posteingang)` | Testnachricht (test.ping) bzw. die Mail im Posteingang (inbox.*). Siehe [Mail-Objekt (Posteingang)](#obj-inbox-message). |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "inbox.auto_replied",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "draft": {
      "id": 5503,
      "mailbox_id": 2,
      "subject": "Re: Frage zu Hochzeitskarten",
      "to": [
        {
          "email": "anna@beispiel.de",
          "name": "Anna Schmidt"
        }
      ],
      "ai_confidence": 91,
      "ai_reason": "Alle Sicherheitsprüfungen bestanden – wird automatisch beantwortet.\nKI-Einschätzung: Preis und Lieferzeit stehen im Wissen für die KI.",
      "ai_flags": [],
      "send_at": "2026-10-09T14:20:00+02:00"
    },
    "message": {
      "id": 9120,
      "mailbox_id": 2,
      "mailbox_email": "hallo@kartenherz.com",
      "folder": "Posteingang",
      "folder_role": "inbox",
      "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
      "message_id": "<CAF-anfrage-4711@mail.beispiel.de>",
      "direction": "in",
      "from": {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      },
      "to": [
        {
          "email": "hallo@kartenherz.com",
          "name": "Kartenherz"
        }
      ],
      "cc": [],
      "subject": "Frage zu Hochzeitskarten",
      "snippet": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
      "received_at": "2026-10-09T14:05:00+02:00",
      "is_seen": false,
      "is_flagged": false,
      "is_answered": false,
      "has_attachments": false,
      "has_calendar": false,
      "category": "lead",
      "category_label": "Leads & Anfragen",
      "priority": "high",
      "sentiment": "positive",
      "intent": "Angebot für 80 Hochzeitskarten",
      "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
      "needs_reply": true,
      "contact_id": 42,
      "campaign_id": null,
      "labels": ["Hochzeit"],
      "snoozed_until": null,
      "follow_up_at": null,
      "replied_at": null
    }
  }
}
```

#### `test.ping` – Test-Ping

Test-Ereignis, das Du auf der Entwickler-Seite oder per API auslöst.

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.message` | `string` | Testnachricht (test.ping) bzw. die Mail im Posteingang (inbox.*). |
| `data.webhook_id` | `integer` | ID des getesteten Webhooks. |
| `data.account` | `object` | Konto, zu dem der Webhook gehört. |
| `data.account.id` | `integer` | ID des Kontos. |
| `data.account.email` | `string` | E-Mail-Adresse des Kontos. |

```json
{
  "id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "event": "test.ping",
  "created_at": "2026-10-09T14:05:00+02:00",
  "data": {
    "message": "Hallo von Persolz! Dieser Test zeigt, dass Dein Endpunkt Webhooks empfängt.",
    "webhook_id": 4,
    "account": {
      "id": 2,
      "email": "du@beispiel.de"
    }
  }
}
```


<a id="webhooks-signatur"></a>
### Webhooks empfangen und verifizieren

Prüfe bei **jeder** Nachricht die Signatur – nur so weißt Du, dass sie wirklich von Persolz kommt und unterwegs nicht verändert wurde. Der Header `X-Persolz-Signature` sieht so aus: `t=1791560735,v1=5f3a8c…`

1. **Rohen Body lesen** – genau die Bytes, die angekommen sind. Nicht erst als JSON einlesen und wieder ausgeben, sonst stimmt die Signatur nicht mehr.
2. **Header zerlegen:** `t` ist der Zeitstempel (Unix-Sekunden), `v1` die Signatur.
3. **Erwartete Signatur berechnen:** HMAC-SHA256 mit Deinem Secret über `"<t>.<roher Body>"`, als Hex-String.
4. **Zeitkonstant vergleichen** (`hash_equals`, `timingSafeEqual`, `compare_digest`) und Zeitstempel ablehnen, die älter als **5 Minuten** sind (Schutz vor Wiederholungsangriffen).
5. **Schnell antworten** (`200`) und aufwendige Arbeit danach erledigen. Entdopple über `X-Persolz-Event-Id` – bei Wiederholungen bleibt sie gleich.

Zum Testen schickt der Button **„Test senden“** auf der Entwickler-Seite (oder `POST /webhooks/{id}/test`) ein Ereignis `test.ping`. Nach einem Secret-Wechsel (`rotate_secret`) ist das alte Secret sofort ungültig.

**Signatur prüfen**

*PHP*

```php
<?php
// webhook.php – empfängt Ereignisse von Persolz
$secret = getenv('PERSOLZ_WEBHOOK_SECRET'); // whsec_… aus „API & Doku“ → Webhooks

$body   = file_get_contents('php://input');          // 1. ROHEN Body lesen
$header = $_SERVER['HTTP_X_PERSOLZ_SIGNATURE'] ?? ''; //    t=1791560735,v1=5f3a…

// 2. Header zerlegen
$t = 0;
$signatures = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
    if ($k === 't') { $t = (int) $v; }
    if ($k === 'v1') { $signatures[] = $v; }
}

// 3. Erwartete Signatur berechnen und zeitkonstant vergleichen
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$valid = false;
foreach ($signatures as $sig) {
    $valid = $valid || hash_equals($expected, $sig);
}

// 4. Zu alte Zeitstempel ablehnen (Schutz vor Replay)
if (!$valid || abs(time() - $t) > 300) {
    http_response_code(400);
    exit('Ungültige Signatur');
}

// 5. Schnell bestätigen, dann verarbeiten
$event = json_decode($body, true);
http_response_code(200);
echo 'ok';

// Doppelte Zustellungen erkennen (gleiche Event-ID bei Wiederholungen)
$eventId = $_SERVER['HTTP_X_PERSOLZ_EVENT_ID'] ?? $event['id'];
// if (bereitsVerarbeitet($eventId)) return;

switch ($event['event']) {
    case 'contact.created':
        // $event['data']['contact']['email'] …
        break;
    case 'contact.unsubscribed':
        // Kunde im Shop als „keine Werbung“ markieren
        break;
}
```

*Node.js*

```javascript
// webhook.js – Node.js 18+ ohne weitere Pakete
const http = require('node:http');
const crypto = require('node:crypto');

const SECRET = process.env.PERSOLZ_WEBHOOK_SECRET; // whsec_…

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('=', 2))
  );
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

http.createServer((req, res) => {
  let raw = '';
  req.setEncoding('utf8');
  req.on('data', (chunk) => { raw += chunk; });
  req.on('end', () => {
    // Wichtig: den ROHEN Body prüfen – nicht JSON.stringify(JSON.parse(raw))
    if (!verify(raw, req.headers['x-persolz-signature'] || '')) {
      res.writeHead(400).end('Ungültige Signatur');
      return;
    }
    res.writeHead(200).end('ok'); // sofort bestätigen (max. 4 Sekunden)

    const event = JSON.parse(raw);
    console.log(event.event, req.headers['x-persolz-event-id'], event.data);
  });
}).listen(3000);
```

*Python*

```python
# webhook.py – mit Flask (pip install flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PERSOLZ_WEBHOOK_SECRET"].encode()

@app.post("/hooks/persolz")
def persolz_webhook():
    raw = request.get_data()  # roher Body (bytes)
    parts = dict(p.strip().split("=", 1) for p in
                 request.headers.get("X-Persolz-Signature", "").split(",") if "=" in p)
    t = int(parts.get("t", 0))
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - t) > 300:
        abort(400)

    event = request.get_json()
    print(event["event"], request.headers.get("X-Persolz-Event-Id"))
    return "ok", 200
```

**Test-Ping auslösen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/webhooks/4/test \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/webhooks/4/test');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/webhooks/4/test', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/webhooks/4/test",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

<a id="funnel"></a>
## Funnel-Integration (z. B. kartenherz.com)

Dein Funnel sammelt Leads – Persolz macht daraus Beziehungen. So landet jede Anmeldung von Deiner Landingpage (hier am Beispiel kartenherz.com) in der richtigen Liste und startet automatisch Deine Seifenoper.

### Welcher Weg passt zu Dir?

| | A) Server-seitig | B) HTML-Formular | C) JavaScript + Schlüssel |
|---|---|---|---|
| **Ideal für** | eigener Formular-Handler, Shop, WordPress-Plugin | Landingpage-Baukästen, statische Seiten | geschützte Bereiche, interne Tools |
| **API-Schlüssel** | ja – bleibt geheim auf dem Server | **nein** (öffentlicher Listen-Token) | ja – im Browser sichtbar |
| **Liste & Tags** | ✓ | ✓ | ✓ |
| **Seifenoper, eigene Felder** | ✓ direkt | über die Listen-Automation | ✓ direkt |
| **Sicherheit** | sehr hoch | hoch (Spamschutz eingebaut) | nur mit Einschränkungen |

**Vorbereitung:** Lege in Persolz eine Liste an (z. B. „Kartenherz Funnel“) und eine Seifenoper (z. B. „Willkommen bei Kartenherz“). Die IDs und den öffentlichen Token (`public_token`) der Liste liefert `GET /lists`. Für B stellst Du in der Seifenoper den Auslöser **„Beim Eintrag in Liste“** ein – dann startet sie automatisch.

**IDs und Token herausfinden**

*cURL*

```bash
curl https://persolz.com/api/v1/lists \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/lists');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/lists', {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/lists",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

<a id="funnel-server"></a>
### A) Server-seitig mit PHP (empfohlen)

Das Formular auf kartenherz.com schickt die Daten an **Dein eigenes Skript** (`anmelden.php`). Erst Dein Server spricht mit Persolz – der Schlüssel verlässt ihn nie.

Das Skript prüft die Eingaben, ruft `POST /contacts` auf und übergibt in einem Rutsch: Liste (`list_ids`), Tags, eigene Felder und die Seifenoper (`soap_sequence_id`). Der Kontakt wird angelegt oder – falls er schon existiert – aktualisiert.

- **Double-Opt-in:** Ist die Liste darauf eingestellt, bekommt der Kontakt eine Bestätigungsmail und bleibt `pending`. Eine Seifenoper kann erst nach der Bestätigung starten – nutze dafür den Auslöser „Beim Eintrag in Liste“ oder die Willkommens-Seifenoper der Liste. `result.soap_enrollment` sagt Dir, was passiert ist.
- **Einwilligung:** `consent: true` bestätigt eine neue Einwilligung – nötig, um früher abgemeldete Adressen wieder anzumelden.
- **Doppelte Anfragen:** Der `Idempotency-Key` wird aus dem Inhalt der Anmeldung gebildet – schickt jemand das Formular doppelt ab, passiert trotzdem alles nur einmal.

**anmelden.php**

```php
<?php
// anmelden.php – empfängt das Funnel-Formular von kartenherz.com (server-seitig)
const PERSOLZ_API = 'https://persolz.com/api/v1';
const LIST_ID     = 3;   // Liste „Kartenherz Funnel“
const SEQUENCE_ID = 7;   // Seifenoper „Willkommen bei Kartenherz“

$apiKey = getenv('PERSOLZ_API_KEY'); // Schlüssel nur auf dem Server, nie im HTML

// 1. Eingaben prüfen (Honeypot „website“ muss leer sein)
$email = trim($_POST['email'] ?? '');
if (!empty($_POST['website']) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    header('Location: /anmeldung?fehler=email', true, 303);
    exit;
}

// 2. Kontakt anlegen oder aktualisieren – inkl. Liste, Tags und Seifenoper
$payload = [
    'email'            => $email,
    'first_name'       => trim($_POST['vorname'] ?? ''),
    'list_ids'         => [LIST_ID],
    'tags'             => ['funnel', 'kartenherz'],
    'custom_fields'    => ['quelle' => $_POST['utm_source'] ?? 'direkt'],
    'soap_sequence_id' => SEQUENCE_ID,
    'consent'          => isset($_POST['einwilligung']),
];

$requestBody = json_encode($payload);

$ch = curl_init(PERSOLZ_API . '/contacts');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        // gleiche Anmeldung = gleicher Schlüssel → doppeltes Absenden legt nichts doppelt an
        'Idempotency-Key: funnel-' . hash('sha256', $requestBody),
    ],
    CURLOPT_POSTFIELDS     => $requestBody,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
]);
$body   = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode($body, true);

// 3. Ergebnis auswerten
if ($status === 200 || $status === 201) {
    $result = $json['data']['result'];
    // pending = Double-Opt-in: die Seifenoper startet nach der Bestätigung
    $ziel = $result['status'] === 'pending' ? '/bitte-bestaetigen' : '/danke';
    header('Location: ' . $ziel, true, 303);
    exit;
}

error_log('Persolz-Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body)
    . ' (Request-ID ' . ($json['error']['request_id'] ?? '-') . ')');
header('Location: /anmeldung?fehler=technik', true, 303);
```

**Formular auf der Landingpage**

```html
<!-- Formular auf kartenherz.com – sendet an Dein eigenes anmelden.php -->
<form action="/anmelden.php" method="post">
  <label>Vorname <input name="vorname" autocomplete="given-name"></label>
  <label>E-Mail <input name="email" type="email" required autocomplete="email"></label>
  <!-- Honeypot gegen Bots: per CSS ausblenden, muss leer bleiben -->
  <input name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">
  <label><input type="checkbox" name="einwilligung" value="1" required>
    Ja, ich möchte die Kartenherz-Mails erhalten (Abmeldung jederzeit möglich).</label>
  <button type="submit">Gratis-Kartenset sichern</button>
</form>
```

<a id="funnel-html"></a>
### B) Reines HTML-Formular – ohne Server-Code

Jede Liste hat eine **öffentliche Anmelde-URL**: `POST /subscribe/{token}`. Sie braucht keinen API-Schlüssel und darf von jeder Website aufgerufen werden. Spamschutz ist eingebaut: Honeypot-Feld `website`, max. 10 Anmeldungen pro Minute je IP und 200 pro Tag je Liste.

- **Weiterleitung:** Mit `success_url` landet der Besucher nach der Anmeldung auf Deiner Danke-Seite (`303`, mit `?status=subscribed` bzw. `?status=pending` bei Double-Opt-in). Bei Fehlern geht es zu `error_url` mit `?error=…&code=…` – ohne `error_url` zeigt Persolz eine kleine Fehlerseite. Trägst Du bei der Liste **Erlaubte Weiterleitungs-Domains** ein (Web-Oberfläche oder `redirect_domains`), werden Ziele auf anderen Hosts ignoriert; `error_url` gilt nur bei gültigem Listen-Token.
- **Tags** als kommagetrennter Text (max. 10). Die Seifenoper startet über den Listen-Auslöser bzw. nach der Double-Opt-in-Bestätigung.
- **Noch einfacher:** Bette das fertige Formular per `<iframe>` ein (`GET /subscribe/{token}/form`) – Felder, Button-Text, Farben und Weiterleitung stellst Du per URL ein.
- **Mit JavaScript:** Sendest Du JSON (`Accept: application/json`), bekommst Du statt der Weiterleitung eine JSON-Antwort (`201`/`200`, Fehler `422`/`429`) – ideal für Formulare ohne Seitenwechsel.

**HTML-Formular**

```html
<!-- Reines HTML – kein Server-Code, kein API-Schlüssel nötig -->
<form action="https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c"
      method="post" accept-charset="UTF-8">
  <input type="hidden" name="success_url" value="https://kartenherz.com/danke">
  <input type="hidden" name="error_url" value="https://kartenherz.com/anmeldung">
  <input type="hidden" name="tags" value="funnel,kartenherz">

  <label>Vorname <input name="first_name" autocomplete="given-name"></label>
  <label>E-Mail <input name="email" type="email" required autocomplete="email"></label>

  <!-- Honeypot: muss leer bleiben, Bots füllen es aus -->
  <div style="position:absolute;left:-9999px" aria-hidden="true">
    <input name="website" tabindex="-1" autocomplete="off">
  </div>

  <label><input type="checkbox" name="consent" value="1" required>
    Ja, ich möchte E-Mails von Kartenherz erhalten.</label>
  <button type="submit">Jetzt anmelden</button>
</form>
```

**Als iframe einbetten**

```html
<!-- Alternative: fertiges Formular von Persolz per iframe einbetten -->
<iframe src="https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form?fields=first_name&button=Gratis-Kartenset%20sichern&success_url=https%3A%2F%2Fkartenherz.com%2Fdanke"
        title="Newsletter-Anmeldung" width="100%" height="380"
        style="border:0;max-width:480px" loading="lazy"></iframe>
```

**Per fetch absenden (ohne Schlüssel)**

```javascript
// Formular per JavaScript absenden – ohne API-Schlüssel (CORS ist für alle Seiten offen)
const form = document.querySelector('#anmeldung');
form.addEventListener('submit', async (event) => {
  event.preventDefault();
  const response = await fetch('https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({
      email: form.email.value,
      first_name: form.vorname.value,
      tags: ['funnel', 'kartenherz'],
      website: form.website.value // Honeypot
    })
  });
  const json = await response.json();
  if (response.ok) {
    // json.data.state: "subscribed" oder "pending" (Bestätigungsmail unterwegs)
    form.innerHTML = '<p>' + json.data.message + '</p>';
  } else {
    alert(json.error.message); // z. B. „Bitte prüfe Deine E-Mail-Adresse …“
  }
});
```

<a id="funnel-js"></a>
### C) JavaScript mit Origin-beschränktem Schlüssel

Manchmal soll der Browser mehr können als nur anmelden – etwa in einem **Mitgliederbereich**, in dem eingeloggte Kunden eine Story-Serie starten. Dann kann der Browser die API direkt aufrufen, **aber nur mit einem eigens dafür erstellten, eingeschränkten Schlüssel**:

1. **Scope:** nur `write` (kein `send`, kein `read`, kein `ai`).
2. **Erlaubte Origins:** `https://kartenherz.com` (ggf. `https://*.kartenherz.com`).
3. **Nur für diesen Zweck:** ein eigener Schlüssel, den Du nirgends sonst verwendest – so kannst Du ihn jederzeit gezielt sperren.
4. **Regelmäßig erneuern** und sofort widerrufen, wenn er auffällig wird (API-Protokoll prüfen).

> **Achtung:** Auch ein eingeschränkter Schlüssel ist im Quelltext sichtbar und könnte außerhalb des Browsers missbraucht werden – mit `write` ließen sich z. B. Kontakte ändern oder löschen. Für **öffentliche** Seiten nimm deshalb immer Weg **B** oder **A**.

**Kontakt aus dem Browser eintragen**

```javascript
// Nur mit einem eingeschränkten Schlüssel: Scope „Schreiben“, erlaubte Origin
// https://kartenherz.com, nur für diesen Zweck. Der Schlüssel ist im Browser sichtbar!
const PERSOLZ_KEY = 'pz_live_DEIN_API_SCHLUESSEL';

async function persolzAnmelden(email, vorname) {
  const response = await fetch('https://persolz.com/api/v1/contacts', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + PERSOLZ_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': crypto.randomUUID()
    },
    body: JSON.stringify({
      email,
      first_name: vorname,
      list_ids: [3],
      tags: ['mitgliederbereich'],
      soap_sequence_id: 7,
      consent: true
    })
  });
  const json = await response.json();
  if (!response.ok) {
    // 403 forbidden mit reason "origin_not_allowed" = Seite nicht freigegeben
    throw new Error(json.error.message);
  }
  return json.data; // Kontakt inkl. result.soap_enrollment
}
```

### Seifenoper gezielt starten

Es gibt drei Wege, einen Kontakt in eine Seifenoper zu bringen:

- **Beim Anlegen:** `soap_sequence_id` in `POST /contacts` (nur für aktive, also nicht `pending`-Kontakte).
- **Nachträglich:** `POST /sequences/{id}/enroll` mit `contact_ids`, `emails` oder einer ganzen Liste (`list_id`) – optional mit `start_date` (`tomorrow`, `2026-10-12` …). Unbekannte Adressen werden dabei als Kontakte angelegt.
- **Automatisch:** Auslöser „Beim Eintrag in Liste“ der Seifenoper oder die Willkommens-Seifenoper der Liste (`welcome_soap_id`). Das funktioniert mit allen drei Wegen und auch mit Double-Opt-in.

Mit dem Webhook `soap.enrolled` erfährt Dein System, wann jemand startet, mit `soap.completed`, wann er alle Tage erhalten hat.

**Kontakte aufnehmen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/sequences/7/enroll \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": ["anna@beispiel.de"],
    "start_date": "tomorrow"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/sequences/7/enroll');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'emails' => ['anna@beispiel.de'],
        'start_date' => 'tomorrow',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/sequences/7/enroll', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    emails: ['anna@beispiel.de'],
    start_date: 'tomorrow'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/sequences/7/enroll",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "emails": ["anna@beispiel.de"],
        "start_date": "tomorrow",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

<a id="transaktional"></a>
## Transaktionale E-Mails per API mit Platzhaltern

Bestellbestätigung, Terminbestätigung, Follow-up nach dem Kauf: Mit `POST /emails/send` verschickst Du einzelne E-Mails direkt aus Deinem System – über Dein eigenes SMTP-Konto, mit Platzhaltern, Vorlagen und Anhängen.

### Eine Bestellbestätigung senden

- **`to`**: eine E-Mail-Adresse, mehrere, oder Kontakt-IDs. Unbekannte Adressen bekommen die Mail trotzdem; ist die Adresse ein Kontakt, werden seine Daten (`{{vorname}}`, eigene Felder …) eingesetzt.
- **`placeholders`**: Werte nur für diese Anfrage, z. B. Bestellnummer und Betrag. Für jeden Kunden eine eigene Anfrage.
- **`Idempotency-Key`**: Nimm etwas Eindeutiges aus Deinem Shop, z. B. `shop-bestellung-4711` – dann geht die Mail garantiert nur einmal raus, egal wie oft Dein Shop es versucht.
- **`track_opens` / `track_clicks`**: Für rein informative Mails kannst Du das Tracking abschalten.
- **`scheduled_at`**: Für Follow-ups in der Zukunft, z. B. „Wie gefällt Dir Dein Kartenset?“ drei Tage später.

Die API antwortet mit `202 Accepted` und einer `campaign_id`. Wichtig: **Abgemeldete und unbestätigte (`pending`) Adressen werden immer übersprungen** – auch bei Transaktionsmails.

**Bestellbestätigung**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/emails/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: shop-bestellung-4711" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "anna@beispiel.de",
    "subject": "Danke für Deine Bestellung #{{bestellnummer}}, {{vorname|Du}}!",
    "html": "<p>Hallo {{vorname|Du}},</p><p>Deine Bestellung <strong>#{{bestellnummer}}</strong> über {{betrag}} ist eingegangen.</p><p>Lieferung voraussichtlich am {{lieferdatum}}.</p>",
    "placeholders": {
      "bestellnummer": "4711",
      "betrag": "24,90 €",
      "lieferdatum": "14.10.2026"
    },
    "from_name": "Kartenherz",
    "reply_to": "hallo@kartenherz.com",
    "track_opens": false
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/emails/send');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: shop-bestellung-4711',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to' => 'anna@beispiel.de',
        'subject' => 'Danke für Deine Bestellung #{{bestellnummer}}, {{vorname|Du}}!',
        'html' => '<p>Hallo {{vorname|Du}},</p><p>Deine Bestellung <strong>#{{bestellnummer}}</strong> über {{betrag}} ist eingegangen.</p><p>Lieferung voraussichtlich am {{lieferdatum}}.</p>',
        'placeholders' => [
            'bestellnummer' => '4711',
            'betrag' => '24,90 €',
            'lieferdatum' => '14.10.2026',
        ],
        'from_name' => 'Kartenherz',
        'reply_to' => 'hallo@kartenherz.com',
        'track_opens' => false,
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/emails/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'shop-bestellung-4711'
  },
  body: JSON.stringify({
    to: 'anna@beispiel.de',
    subject: 'Danke für Deine Bestellung #{{bestellnummer}}, {{vorname|Du}}!',
    html: '<p>Hallo {{vorname|Du}},</p><p>Deine Bestellung <strong>#{{bestellnummer}}</strong> über {{betrag}} ist eingegangen.</p><p>Lieferung voraussichtlich am {{lieferdatum}}.</p>',
    placeholders: {
      bestellnummer: '4711',
      betrag: '24,90 €',
      lieferdatum: '14.10.2026'
    },
    from_name: 'Kartenherz',
    reply_to: 'hallo@kartenherz.com',
    track_opens: false
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/emails/send",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": "shop-bestellung-4711",
    },
    json={
        "to": "anna@beispiel.de",
        "subject": "Danke für Deine Bestellung #{{bestellnummer}}, {{vorname|Du}}!",
        "html": "<p>Hallo {{vorname|Du}},</p><p>Deine Bestellung <strong>#{{bestellnummer}}</strong> über {{betrag}} ist eingegangen.</p><p>Lieferung voraussichtlich am {{lieferdatum}}.</p>",
        "placeholders": {
            "bestellnummer": "4711",
            "betrag": "24,90 €",
            "lieferdatum": "14.10.2026",
        },
        "from_name": "Kartenherz",
        "reply_to": "hallo@kartenherz.com",
        "track_opens": False,
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Antwort (202)**

```json
{
  "data": {
    "campaign_id": 813,
    "status": "sending",
    "scheduled_at": null,
    "recipients": 1,
    "queued": 1,
    "sent": 1,
    "failed": 0,
    "message": "Versand abgeschlossen: 1 E-Mail verschickt."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

### Mit Vorlage und Anhang

Statt eigenem HTML kannst Du eine Vorlage verwenden: `template_key` für die Bibliothek (Übersicht: `GET /templates?category=email`) oder `template_id` für eigene Vorlagen. Betreff und Vorschautext kommen aus der Vorlage, wenn Du keine angibst.

Anhänge lädst Du einmal hoch (`POST /attachments`, max. 10 MB) und gibst danach nur noch die ID in `attachment_ids` mit – z. B. Rechnung, AGB oder ein Gutschein als PDF.

Bilder (JPG, PNG, GIF, WebP) aus der Anhang-Cloud baust Du direkt ins HTML ein: `POST /attachments` und `GET /attachments/{id}` liefern dafür `image_url` (die Liste `GET /attachments` zeigt nur bereits vorhandene Links) – eine öffentliche, nicht erratbare Adresse für `<img src="…">`. So wird das Bild nicht angehängt, sondern beim Öffnen der Mail geladen.

**Anhang hochladen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/attachments \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -F "file=@Rechnung-4711.pdf" \
  -F "description=Rechnung zur Bestellung 4711"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/attachments');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_POSTFIELDS     => [
        'file' => new CURLFile('Rechnung-4711.pdf'),
        'description' => 'Rechnung zur Bestellung 4711',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const form = new FormData();
form.append('file', document.querySelector('input[type=file]').files[0]); // Rechnung-4711.pdf
form.append('description', 'Rechnung zur Bestellung 4711');

const response = await fetch('https://persolz.com/api/v1/attachments', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  },
  body: form
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/attachments",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    files={"file": open("Rechnung-4711.pdf", "rb")},
    data={
        "description": "Rechnung zur Bestellung 4711",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Mail mit Vorlage und Anhang**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/emails/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: shop-rechnung-4711" \
  -H "Content-Type: application/json" \
  -d '{
    "to": [42],
    "template_key": "email-07",
    "subject": "Deine Rechnung #{{bestellnummer}}",
    "placeholders": {
      "bestellnummer": "4711"
    },
    "attachment_ids": [9]
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/emails/send');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: shop-rechnung-4711',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'to' => [42],
        'template_key' => 'email-07',
        'subject' => 'Deine Rechnung #{{bestellnummer}}',
        'placeholders' => [
            'bestellnummer' => '4711',
        ],
        'attachment_ids' => [9],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/emails/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'shop-rechnung-4711'
  },
  body: JSON.stringify({
    to: [42],
    template_key: 'email-07',
    subject: 'Deine Rechnung #{{bestellnummer}}',
    placeholders: {
      bestellnummer: '4711'
    },
    attachment_ids: [9]
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/emails/send",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": "shop-rechnung-4711",
    },
    json={
        "to": [42],
        "template_key": "email-07",
        "subject": "Deine Rechnung #{{bestellnummer}}",
        "placeholders": {
            "bestellnummer": "4711",
        },
        "attachment_ids": [9],
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### Status verfolgen

Mit der `campaign_id` fragst Du jederzeit den Stand ab: `GET /emails/{id}` liefert Status und Kennzahlen (versendet, geöffnet, geklickt, fehlgeschlagen). Oder Du lässt Dich per Webhook benachrichtigen: `campaign.sent`, sobald alles raus ist, sowie `email.opened` und `email.clicked`.

**Status abfragen**

*cURL*

```bash
curl https://persolz.com/api/v1/emails/813 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/emails/813');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/emails/813', {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/emails/813",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

<a id="seinfeld-planen"></a>
## Seinfeld-Mails per API planen

Seinfeld-Mails sind kurze, unterhaltsame Story-Mails im festen Rhythmus – über scheinbar „nichts“, mit einer Brücke zu Deinem Angebot. Per API füllst Du die Themenliste, schreibst oder generierst Mails und planst sie in Deinen Rhythmus ein.

### 1. Rhythmus und Empfänger festlegen

Mit `PUT /seinfeld/settings` stellst Du ein, **wann** (z. B. werktags um 9:00 Uhr) und **an wen** (z. B. Liste 3) Deine Seinfeld-Mails gehen. Nur die übergebenen Felder ändern sich. Mit `auto_generate` schreibt die KI Entwürfe für freie Termine, mit `auto_send` sogar ohne Deine Freigabe.

**Einstellungen speichern**

*cURL*

```bash
curl -X PUT https://persolz.com/api/v1/seinfeld/settings \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "frequency": "weekdays",
    "send_time": "09:00",
    "audience": {
      "lists": [3]
    },
    "offer_name": "Kartenset-Abo",
    "offer_url": "https://kartenherz.com/abo"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/seinfeld/settings');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'PUT',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'enabled' => true,
        'frequency' => 'weekdays',
        'send_time' => '09:00',
        'audience' => [
            'lists' => [3],
        ],
        'offer_name' => 'Kartenset-Abo',
        'offer_url' => 'https://kartenherz.com/abo',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/seinfeld/settings', {
  method: 'PUT',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    enabled: true,
    frequency: 'weekdays',
    send_time: '09:00',
    audience: {
      lists: [3]
    },
    offer_name: 'Kartenset-Abo',
    offer_url: 'https://kartenherz.com/abo'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.put(
    "https://persolz.com/api/v1/seinfeld/settings",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "enabled": True,
        "frequency": "weekdays",
        "send_time": "09:00",
        "audience": {
            "lists": [3],
        },
        "offer_name": "Kartenset-Abo",
        "offer_url": "https://kartenherz.com/abo",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 2. Themen sammeln

Die Themenliste ist Dein Ideen-Speicher. Mit `topics` legst Du mehrere auf einmal an; doppelte werden übersprungen. Themen mit Stern (`priority`) kommen zuerst dran.

**Themen anlegen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/topics \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "topics": [
      "Der verlorene Schlüssel",
      "Warum ich Briefmarken sammle",
      "Der Kaffeebecher im Büro"
    ]
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/seinfeld/topics');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'topics' => [
            'Der verlorene Schlüssel',
            'Warum ich Briefmarken sammle',
            'Der Kaffeebecher im Büro',
        ],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/seinfeld/topics', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    topics: [
      'Der verlorene Schlüssel',
      'Warum ich Briefmarken sammle',
      'Der Kaffeebecher im Büro'
    ]
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/seinfeld/topics",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "topics": [
            "Der verlorene Schlüssel",
            "Warum ich Briefmarken sammle",
            "Der Kaffeebecher im Büro",
        ],
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 3. Freie Termine finden

`GET /seinfeld/slots` zeigt alle Versandtermine im Zeitraum. Termine mit `is_gap: true` haben noch keine Mail – dort ist Platz für die nächste Geschichte.

**Termine abfragen**

*cURL*

```bash
curl "https://persolz.com/api/v1/seinfeld/slots?from=2026-10-12&to=2026-10-23" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$query = http_build_query([
    'from' => '2026-10-12',
    'to' => '2026-10-23',
]);
$ch = curl_init('https://persolz.com/api/v1/seinfeld/slots?' . $query);
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const params = new URLSearchParams({
  from: '2026-10-12',
  to: '2026-10-23'
});
const response = await fetch('https://persolz.com/api/v1/seinfeld/slots?' + params, {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/seinfeld/slots",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    params={
        "from": "2026-10-12",
        "to": "2026-10-23",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 4. Mail schreiben – selbst oder mit KI

- **Selbst geschrieben:** `POST /seinfeld/emails` legt einen Entwurf an. Mit `topic_id` wird das Thema als verwendet markiert, `story_title` und `story_summary` füttern das Story-Gedächtnis (gegen Wiederholungen).
- **Von der KI:** `POST /seinfeld/generate` schreibt eine Mail aus dem nächsten Thema (`source: "next_topic"`), einem bestimmten Thema oder Deiner Idee. Mit `persist: true` wird sie direkt als Entwurf gespeichert. Das verbraucht KI-Guthaben (Scope `ai`).

**Eigener Entwurf**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/emails \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "html": "<p>Heute Morgen ist mir mein Kaffeebecher …</p>",
    "topic_id": 15,
    "story_title": "Der Kaffeebecher"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/seinfeld/emails');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'subject' => 'Was mein Kaffeebecher über Marketing weiß',
        'html' => '<p>Heute Morgen ist mir mein Kaffeebecher …</p>',
        'topic_id' => 15,
        'story_title' => 'Der Kaffeebecher',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/seinfeld/emails', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    subject: 'Was mein Kaffeebecher über Marketing weiß',
    html: '<p>Heute Morgen ist mir mein Kaffeebecher …</p>',
    topic_id: 15,
    story_title: 'Der Kaffeebecher'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/seinfeld/emails",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "subject": "Was mein Kaffeebecher über Marketing weiß",
        "html": "<p>Heute Morgen ist mir mein Kaffeebecher …</p>",
        "topic_id": 15,
        "story_title": "Der Kaffeebecher",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Mit KI generieren**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/generate \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "next_topic",
    "model": "sonnet",
    "persist": true
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/seinfeld/generate');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'source' => 'next_topic',
        'model' => 'sonnet',
        'persist' => true,
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/seinfeld/generate', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    source: 'next_topic',
    model: 'sonnet',
    persist: true
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/seinfeld/generate",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "source": "next_topic",
        "model": "sonnet",
        "persist": True,
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 5. Freigeben und planen

`POST /seinfeld/emails/{id}/schedule` gibt die Mail frei und plant sie – zum angegebenen `scheduled_at` oder, ohne Angabe, zum **nächsten freien Termin** im Rhythmus. Persolz versendet pünktlich; danach meldet der Webhook `campaign.sent` (Typ `seinfeld`, `ref_id` = ID der Seinfeld-Mail).

Geplant, aber doch nicht? `POST /seinfeld/emails/{id}/unschedule` macht sie wieder zum Entwurf. Alle geplanten Mails liefert `GET /seinfeld/emails?status=planned`.

**Mail planen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/emails/120/schedule \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-10-12T09:00:00+02:00"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/seinfeld/emails/120/schedule');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'scheduled_at' => '2026-10-12T09:00:00+02:00',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/seinfeld/emails/120/schedule', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    scheduled_at: '2026-10-12T09:00:00+02:00'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/seinfeld/emails/120/schedule",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "scheduled_at": "2026-10-12T09:00:00+02:00",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Antwort (gekürzt)**

```json
{
  "data": {
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "status": "scheduled",
    "status_label": "Geplant",
    "scheduled_at": "2026-10-12T09:00:00+02:00",
    "campaign_id": 901
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

<a id="posteingang"></a>
## Posteingang per API

Der Posteingang verbindet Deine IMAP-Postfächer mit Persolz: Die KI sortiert neue Mails, schreibt Antwortentwürfe und beantwortet auf Wunsch sichere Anfragen selbst. Per API holst Du Mails in Dein CRM, antwortest aus Deinem System und gibst KI-Entwürfe frei. Das Postfach selbst verbindest Du in der App (Passwörter gibt die API nie heraus).

### 1. Neue Mails abholen

Persolz ruft Deine Postfächer regelmäßig ab. Brauchst Du es sofort, stößt `POST /mailboxes/{id}/sync` den Abruf an (max. 8 pro Minute). Danach liefert `GET /inbox/messages` die Mails aus dem Zwischenspeicher – mit KI-Kategorie, Priorität, Zusammenfassung und Kontaktzuordnung, aber **ohne Inhalt**.

Zum Abgleichen merkst Du Dir den Zeitpunkt des letzten Abrufs und fragst mit `since` nur Neues ab. Noch besser: Der Webhook **`inbox.received`** meldet jede neue Mail sofort (ohne Newsletter, Abwesenheitsnotizen, Unzustellbarkeitsmeldungen und Spam-Verdacht).

**Jetzt abrufen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/mailboxes/2/sync \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/mailboxes/2/sync');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/mailboxes/2/sync', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/mailboxes/2/sync",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Neue Leads seit gestern**

*cURL*

```bash
curl "https://persolz.com/api/v1/inbox/messages?category=lead&since=2026-10-09T00%3A00%3A00%2B02%3A00" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$query = http_build_query([
    'category' => 'lead',
    'since' => '2026-10-09T00:00:00+02:00',
]);
$ch = curl_init('https://persolz.com/api/v1/inbox/messages?' . $query);
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const params = new URLSearchParams({
  category: 'lead',
  since: '2026-10-09T00:00:00+02:00'
});
const response = await fetch('https://persolz.com/api/v1/inbox/messages?' + params, {
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL'
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.get(
    "https://persolz.com/api/v1/inbox/messages",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    params={
        "category": "lead",
        "since": "2026-10-09T00:00:00+02:00",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 2. Lesen und bearbeiten

`GET /inbox/messages/{id}?include_body=true` liefert Text und **bereinigtes HTML** (ohne Skripte, externe Bilder blockiert), dazu Anhänge, Verlauf und Entwürfe. Der Abruf markiert die Mail **nicht** als gelesen.

`POST /inbox/messages/actions` bearbeitet bis zu 500 Mails auf einmal – genau wie in der App und auch auf dem Mailserver: `read`, `flag`, `archive`, `trash`, `move`, `snooze` (`params.until`), `follow_up` (`params.at`: „Erinnern, falls keine Antwort kommt“), `label`, `set_category` …

**Gelesen und archiviert**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/actions \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [9120, 9121],
    "action": "archive"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/inbox/messages/actions');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'ids' => [9120, 9121],
        'action' => 'archive',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/inbox/messages/actions', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    ids: [9120, 9121],
    action: 'archive'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/inbox/messages/actions",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "ids": [9120, 9121],
        "action": "archive",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Bis Montag zurückstellen**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/actions \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [9120],
    "action": "snooze",
    "params": {
      "until": "2026-10-12T09:00:00+02:00"
    }
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/inbox/messages/actions');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'ids' => [9120],
        'action' => 'snooze',
        'params' => [
            'until' => '2026-10-12T09:00:00+02:00',
        ],
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/inbox/messages/actions', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    ids: [9120],
    action: 'snooze',
    params: {
      until: '2026-10-12T09:00:00+02:00'
    }
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/inbox/messages/actions",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
    },
    json={
        "ids": [9120],
        "action": "snooze",
        "params": {
            "until": "2026-10-12T09:00:00+02:00",
        },
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 3. Antworten

`POST /inbox/messages/{id}/reply` antwortet über das Postfach der Mail – mit Signatur, zitiertem Original und korrektem Verlauf (`In-Reply-To`). Empfänger und Betreff werden vorbelegt. Ohne `send_at` geht die Mail sofort raus (Scope `send`), die Kopie landet im Ordner „Gesendet“.

Neue Mails und Weiterleitungen schreibst Du mit `POST /inbox/drafts` (`kind`: `new`, `reply`, `reply_all`, `forward`) – sofort, geplant (`send_at`) oder als Entwurf (`send: false`). Geplante Mails stoppst Du mit `POST /inbox/drafts/{id}/cancel`.

**Antwort senden**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/9120/reply \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: crm-antwort-9120" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<p>Hallo Anna,</p><p>herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € und sind in 10 Tagen bei Dir.</p>"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/inbox/messages/9120/reply');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: crm-antwort-9120',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'html' => '<p>Hallo Anna,</p><p>herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € und sind in 10 Tagen bei Dir.</p>',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/inbox/messages/9120/reply', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': 'crm-antwort-9120'
  },
  body: JSON.stringify({
    html: '<p>Hallo Anna,</p><p>herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € und sind in 10 Tagen bei Dir.</p>'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests

response = requests.post(
    "https://persolz.com/api/v1/inbox/messages/9120/reply",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": "crm-antwort-9120",
    },
    json={
        "html": "<p>Hallo Anna,</p><p>herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € und sind in 10 Tagen bei Dir.</p>",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 4. KI-Entwürfe freigeben

`POST /inbox/messages/{id}/ai-draft` lässt die KI eine Antwort im Stil Deines Postfachs schreiben – mit Selbsteinschätzung (`confidence`), Risiken und Begründung (Scope `ai`, verbraucht KI-Guthaben). Mit `save: "approval"` landet sie in den Freigaben.

Im Modus „Mit Freigabe“ oder wenn der Autopilot eine Sicherheitsprüfung nicht besteht, meldet der Webhook **`inbox.approval_needed`** den Entwurf. `GET /inbox/approvals` zeigt alle wartenden Entwürfe mit den Gründen; `POST /inbox/approvals/{id}/approve` sendet (optional mit Änderungen), `…/reject` lehnt ab – beides fließt als Erkenntnis in künftige Entwürfe ein.

**Entwurf in die Freigaben**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/9120/ai-draft \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "tone": "herzlich",
    "save": "approval"
  }'
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/inbox/messages/9120/ai-draft');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'tone' => 'herzlich',
        'save' => 'approval',
    ]),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/inbox/messages/9120/ai-draft', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    tone: 'herzlich',
    save: 'approval'
  })
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/inbox/messages/9120/ai-draft",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "tone": "herzlich",
        "save": "approval",
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

**Freigeben und senden**

*cURL*

```bash
curl -X POST https://persolz.com/api/v1/inbox/approvals/5502/approve \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

*PHP*

```php
<?php
$apiKey = 'pz_live_DEIN_API_SCHLUESSEL'; // besser: getenv('PERSOLZ_API_KEY')

$ch = curl_init('https://persolz.com/api/v1/inbox/approvals/5502/approve');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = (string) curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$json = json_decode($body, true);
if ($status >= 400) {
    exit('Fehler ' . $status . ': ' . ($json['error']['message'] ?? $body));
}
print_r($json['data']);
```

*JavaScript*

```javascript
const response = await fetch('https://persolz.com/api/v1/inbox/approvals/5502/approve', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer pz_live_DEIN_API_SCHLUESSEL',
    'Idempotency-Key': crypto.randomUUID()
  }
});
const json = await response.json();
if (!response.ok) {
  throw new Error(`Fehler ${response.status}: ${json.error.message}`);
}
console.log(json.data);
```

*Python*

```python
import requests
import uuid

response = requests.post(
    "https://persolz.com/api/v1/inbox/approvals/5502/approve",
    headers={
        "Authorization": "Bearer pz_live_DEIN_API_SCHLUESSEL",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    timeout=30,
)
result = response.json()
if not response.ok:
    raise SystemExit(f"Fehler {response.status_code}: {result['error']['message']}")
print(result["data"])
```

### 5. Webhooks des Posteingangs

| Ereignis | Wann | Typische Verwendung |
|---|---|---|
| `inbox.received` | neue Mail im Posteingang | Lead ins CRM, Ticket anlegen |
| `inbox.approval_needed` | KI-Entwurf wartet auf Freigabe | Hinweis in Slack/Teams |
| `inbox.auto_replied` | Autopilot hat eine Antwort eingeplant | Protokoll, Stichproben |
| `inbox.replied` | Antwort/Mail aus dem Posteingang gesendet | Status im CRM setzen |

Die Payloads enthalten das [Mail-Objekt](#obj-inbox-message) – nie den Inhalt der Mail. Den holst Du bei Bedarf mit `GET /inbox/messages/{id}?include_body=true`.

<a id="wiederholungen"></a>
## Fehlerbehandlung & Wiederholungen

Robuste Integrationen unterscheiden zwischen Fehlern, die sich von selbst erledigen (kurz warten, nochmal), und Fehlern, die Du beheben musst (Eingaben, Rechte, Tarif).

### Wann wiederholen – und wann nicht?

| Situation | Wiederholen? | So geht's |
|---|---|---|
| Netzwerkfehler, Timeout | ja | mit Pause: 1, 2, 4, 8 … Sekunden (max. 5 Versuche) |
| `429 rate_limited` | ja | genau so lange warten, wie `Retry-After` sagt |
| `500 server_error`, `503 service_unavailable` | ja | mit wachsender Pause; bleibt es dabei, Request-ID an den Support |
| `409` mit `reason: "idempotency_in_progress"` | ja | nach 2 Sekunden mit demselben Key |
| `400`, `401`, `402`, `403`, `404`, `405`, `409`, `413`, `422` | **nein** | Anfrage, Schlüssel, Rechte oder Tarif prüfen |

**Das Wichtigste:** Verwende bei POST-Anfragen für **alle Versuche denselben `Idempotency-Key`**. Dann ist es egal, ob die erste Anfrage schon angekommen war – es passiert trotzdem nur einmal. Protokolliere bei Fehlern immer die `request_id`.

**Anfrage mit Wiederholung**

*PHP*

```php
<?php
/**
 * Persolz-API mit automatischer Wiederholung bei 429, 5xx und Netzwerkfehlern.
 * POST-Anfragen bekommen EINEN Idempotency-Key für alle Versuche.
 */
function persolz(string $method, string $path, ?array $body = null): array
{
    $apiKey = getenv('PERSOLZ_API_KEY');
    $idempotencyKey = bin2hex(random_bytes(16));

    for ($attempt = 1; $attempt <= 5; $attempt++) {
        $headers = ['Authorization: Bearer ' . $apiKey, 'Accept: application/json'];
        if ($body !== null) {
            $headers[] = 'Content-Type: application/json';
        }
        if ($method === 'POST') {
            $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
        }
        $ch = curl_init('https://persolz.com/api/v1' . $path);
        $responseHeaders = [];
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_POSTFIELDS     => $body !== null ? json_encode($body) : null,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$responseHeaders) {
                $p = explode(':', $line, 2);
                if (count($p) === 2) {
                    $responseHeaders[strtolower(trim($p[0]))] = trim($p[1]);
                }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        $retryable = $raw === false || $status === 429 || $status >= 500;
        if (!$retryable) {
            $json = $status === 204 ? [] : (json_decode((string) $raw, true) ?? []);
            if ($status >= 400) {
                // 4xx: Eingaben oder Rechte prüfen – nicht wiederholen
                throw new RuntimeException(($json['error']['message'] ?? 'API-Fehler') . ' [' . $status . ', '
                    . ($json['error']['request_id'] ?? '-') . ']');
            }
            return $json;
        }
        // Wartezeit: Retry-After (bei 429) oder 1, 2, 4, 8 Sekunden
        $wait = (int) ($responseHeaders['retry-after'] ?? 0) ?: 2 ** ($attempt - 1);
        sleep(min($wait, 30));
    }
    throw new RuntimeException('Persolz ist gerade nicht erreichbar – bitte später erneut versuchen.');
}

// Beispiel
$kontakt = persolz('POST', '/contacts', ['email' => 'anna@beispiel.de', 'list_ids' => [3]]);
```

*JavaScript*

```javascript
// Persolz-API mit Wiederholung bei 429, 5xx und Netzwerkfehlern (Node.js 18+)
async function persolz(method, path, body) {
  const idempotencyKey = crypto.randomUUID(); // gleicher Schlüssel für alle Versuche
  for (let attempt = 1; attempt <= 5; attempt++) {
    let response;
    try {
      response = await fetch('https://persolz.com/api/v1' + path, {
        method,
        headers: {
          'Authorization': `Bearer ${process.env.PERSOLZ_API_KEY}`,
          ...(body ? { 'Content-Type': 'application/json' } : {}),
          ...(method === 'POST' ? { 'Idempotency-Key': idempotencyKey } : {})
        },
        body: body ? JSON.stringify(body) : undefined
      });
    } catch (networkError) {
      response = null; // Netzwerkfehler → erneut versuchen
    }
    if (response && response.status !== 429 && response.status < 500) {
      const json = response.status === 204 ? {} : await response.json();
      if (!response.ok) {
        // 4xx: nicht wiederholen, sondern Eingaben/Rechte prüfen
        throw new Error(`${json.error.message} [${response.status}, ${json.error.request_id}]`);
      }
      return json;
    }
    const retryAfter = Number(response?.headers.get('Retry-After')) || 2 ** (attempt - 1);
    await new Promise((r) => setTimeout(r, Math.min(retryAfter, 30) * 1000));
  }
  throw new Error('Persolz ist gerade nicht erreichbar – bitte später erneut versuchen.');
}

const kontakt = await persolz('POST', '/contacts', { email: 'anna@beispiel.de', list_ids: [3] });
```

### Häufige Fehler und ihre Lösung

- **`401` obwohl der Schlüssel stimmt:** Manche Server (z. B. Apache mit CGI) entfernen den `Authorization`-Header auf **Deiner** Seite, bevor Dein Code ihn sendet – prüfe, ob er wirklich mitgeht, oder nutze `X-API-Key`.
- **`402 plan_required`:** Die API oder die Funktion (z. B. KI, A/B-Tests) ist im Tarif nicht enthalten → [Persolz Pro](https://persolz.com/preise).
- **`402 limit_reached`:** Ein Tariflimit ist erreicht (Kontakte, Tagesversände, Speicher …) – Details in der Meldung, Überblick mit `GET /plan/limits`.
- **`403 forbidden`:** Scope fehlt, IP oder Origin nicht freigegeben (`reason` verrät es) oder das Konto ist gesperrt.
- **`404 not_found`:** Die ID gibt es nicht – oder sie gehört zu einem anderen Konto.
- **`409 conflict`:** Der Zustand passt nicht (z. B. Newsletter schon versendet, Adresse schon gesperrt, Idempotency-Key mit anderem Inhalt).
- **`422 validation_error`:** In `error.fields` steht je Feld, was nicht passt.

<a id="api-referenz"></a>
## API-Referenz

Alle Pfade sind relativ zur Basis-URL `https://persolz.com/api/v1`. Die Beispiele zeigen cURL; Varianten in PHP, JavaScript und Python findest Du in der Online-Doku.

<a id="ref-system"></a>
### System

API-Index und die OpenAPI-Beschreibung (öffentlich).

<a id="system-index"></a>
#### API-Index

`GET /api/v1`

Öffentlich (kein API-Schlüssel)

Name, Version und Links zur Dokumentation und zur OpenAPI-Beschreibung. Ohne API-Schlüssel abrufbar.

```bash
curl https://persolz.com/api/v1
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.name` | `string` | Name der API. |
| `data.version` | `string` | Version der API. |
| `data.base_url` | `string` | Basis-URL der API. |
| `data.docs_url` | `string` | Link zu dieser Dokumentation. |
| `data.openapi_url` | `string` | Link zur OpenAPI-Beschreibung. |
| `data.time` | `string (Datum/Zeit)` | Aktuelle Serverzeit (Europe/Berlin). |

```json
{
  "data": {
    "name": "Persolz REST API",
    "version": "1.1.0",
    "base_url": "https://persolz.com/api/v1",
    "docs_url": "https://persolz.com/api-docs",
    "openapi_url": "https://persolz.com/api/v1/openapi.json",
    "time": "2026-10-09T14:05:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

<a id="system-openapi"></a>
#### OpenAPI-Beschreibung

`GET /api/v1/openapi.json`

Öffentlich (kein API-Schlüssel)

Die vollständige, maschinenlesbare Beschreibung dieser API im Format OpenAPI 3.1 (z. B. für Postman, Insomnia, Code-Generatoren). Ohne Umschlag {data}.

```bash
curl https://persolz.com/api/v1/openapi.json
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.openapi` | `string` | Version der OpenAPI-Spezifikation (3.1.0). |
| `data.info` | `object` | Titel, Version, Beschreibung und Kontakt der API. |
| `data.info.title` | `string` | Titel. |
| `data.info.version` | `string` | Version der API. |
| `data.paths` | `object` | Alle Endpunkte mit Parametern, Request-Bodys und Antworten. |

```json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Persolz REST API",
    "version": "1.1.0"
  },
  "paths": {}
}
```

<a id="ref-account"></a>
### Konto & Tarif

Dein Konto, Tarif, Limits, Verbrauch und KI-Guthaben.

<a id="account-me"></a>
#### Konto, Tarif & Verbrauch

`GET /api/v1/me`

Scope: `read`

Dein Konto, der aktive Tarif mit Limits und Funktionen, der aktuelle Verbrauch (Kontakte, Listen, Speicher, heutige Versände …), das KI-Guthaben und der verwendete API-Schlüssel.

```bash
curl https://persolz.com/api/v1/me \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.account` | `object` | Dein Konto. |
| `data.account.id` | `integer` | ID Deines Kontos. |
| `data.account.email` | `string` | E-Mail-Adresse. |
| `data.account.first_name` | `string` | Vorname. |
| `data.account.last_name` | `string` | Nachname. |
| `data.account.company` | `string` | Firma. |
| `data.account.timezone` | `string` | Zeitzone des Kontos. |
| `data.account.email_verified` | `boolean` | E-Mail-Adresse bestätigt. |
| `data.account.created_at` | `string (Datum/Zeit)` | Registriert am. |
| `data.plan` | `object` | Dein Tarif. |
| `data.plan.key` | `string` | Tarif-Schlüssel: free, trial oder pro. |
| `data.plan.label` | `string` | Tarif in Worten. |
| `data.plan.trial_days_left` | `integer` | Verbleibende Tage der Testphase. |
| `data.plan.trial_ends_at` | `string · null` | Ende der Testphase oder null. |
| `data.plan.subscription_status` | `string` | Status des Abos (z. B. active) oder null. |
| `data.features` | `object` | Funktionen des Tarifs (true = freigeschaltet). |
| `data.features.ai` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.api` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.seinfeld` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.soap` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.newsletter` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.email` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.ab_tests` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.features.calendar` | `boolean` | true, wenn diese Funktion in Deinem Tarif enthalten ist. |
| `data.limits` | `object` | Limits des Tarifs (-1 = unbegrenzt). |
| `data.limits.max_lists` | `integer` | Limit (-1 = unbegrenzt). |
| `data.limits.max_contacts` | `integer` | Limit (-1 = unbegrenzt). |
| `data.limits.emails_per_day` | `integer` | Limit (-1 = unbegrenzt). |
| `data.limits.storage_mb` | `integer` | Limit (-1 = unbegrenzt). |
| `data.usage` | `object` | Aktueller Verbrauch. |
| `data.usage.contacts` | `integer` | Anzahl Kontakte. |
| `data.usage.lists` | `integer` | Anzahl Listen. |
| `data.usage.soap_sequences` | `integer` | Anzahl Seifenopern. |
| `data.usage.smtp_accounts` | `integer` | Verbundene SMTP-Konten. |
| `data.usage.custom_templates` | `integer` | Eigene Vorlagen. |
| `data.usage.storage_bytes` | `integer` | Belegter Speicher der Anhang-Cloud in Bytes. |
| `data.usage.active_ab_tests` | `integer` | Laufende A/B-Tests. |
| `data.usage.today` | `object` | Heute gestartete Versände je Typ (für die Tageslimits). |
| `data.usage.today.email` | `integer` | Heute gestartete Versände dieses Typs. |
| `data.usage.today.newsletter` | `integer` | Heute gestartete Versände dieses Typs. |
| `data.usage.today.seinfeld` | `integer` | Heute gestartete Versände dieses Typs. |
| `data.ai` | `object` | KI-Guthaben und -Verbrauch im laufenden Monat. |
| `data.ai.credit` | `number` | Monatliches KI-Guthaben in Euro. |
| `data.ai.used` | `number` | Im Monat verbraucht (Euro). |
| `data.ai.remaining` | `number` | Verbleibendes KI-Guthaben (Euro). |
| `data.ai.requests` | `integer` | KI-Anfragen im laufenden Monat. |
| `data.ai.limit` | `integer` | Max. KI-Anfragen pro Monat (0 = unbegrenzt). |
| `data.ai.period` | `string` | Abrechnungsmonat (YYYY-MM). |
| `data.ai.allowed` | `boolean` | KI ist für Dich gerade nutzbar (Tarif und Guthaben). |
| `data.api_key` | `object` | Der API-Schlüssel dieser Anfrage. |
| `data.api_key.id` | `integer` | ID des Schlüssels. |
| `data.api_key.name` | `string` | Name des Schlüssels. |
| `data.api_key.prefix` | `string` | Anfang des Schlüssels (zum Wiedererkennen). |
| `data.api_key.scopes` | `array<string>` | Berechtigungen (["*"] = alle). |
| `data.api_key.rate_limit` | `integer` | Erlaubte Anfragen pro Minute. |

```json
{
  "data": {
    "account": {
      "id": 2,
      "email": "du@beispiel.de",
      "first_name": "Daniel",
      "last_name": "Scholz",
      "company": "Kartenherz",
      "timezone": "Europe/Berlin",
      "email_verified": true,
      "created_at": "2026-09-01T10:00:00+02:00"
    },
    "plan": {
      "key": "pro",
      "label": "Pro",
      "trial_days_left": 0,
      "trial_ends_at": null,
      "subscription_status": "active"
    },
    "features": {
      "ai": true,
      "api": true,
      "seinfeld": true,
      "soap": true,
      "newsletter": true,
      "email": true,
      "ab_tests": true,
      "calendar": true
    },
    "limits": {
      "max_lists": -1,
      "max_contacts": -1,
      "emails_per_day": -1,
      "storage_mb": 2000
    },
    "usage": {
      "contacts": 1250,
      "lists": 3,
      "soap_sequences": 2,
      "smtp_accounts": 1,
      "custom_templates": 4,
      "storage_bytes": 5242880,
      "active_ab_tests": 1,
      "today": {
        "email": 3,
        "newsletter": 1,
        "seinfeld": 1
      }
    },
    "ai": {
      "credit": 10.0,
      "used": 2.31,
      "remaining": 7.69,
      "requests": 12,
      "limit": 0,
      "period": "2026-10",
      "allowed": true
    },
    "api_key": {
      "id": 5,
      "name": "Funnel kartenherz.com",
      "prefix": "pz_live_AbCdEf",
      "scopes": ["*"],
      "rate_limit": 120
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="account-limits"></a>
#### Tarif-Limits mit Verbrauch

`GET /api/v1/plan/limits`

Scope: `read`

Alle Limits des Tarifs mit aktuellem Verbrauch und Rest (-1 = unbegrenzt) sowie alle Funktionen mit Freischaltung.

```bash
curl https://persolz.com/api/v1/plan/limits \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.plan` | `string` | Tarif-Schlüssel: free, trial oder pro. |
| `data.plan_label` | `string` | Tarif in Worten. |
| `data.limits` | `object` | Limits des Tarifs (-1 = unbegrenzt). |
| `data.limits.max_contacts` | `object` | Limit mit Verbrauch und Rest. |
| `data.limits.max_contacts.label` | `string` | Name des Limits. |
| `data.limits.max_contacts.limit` | `integer` | Limit des Tarifs (-1 = unbegrenzt). |
| `data.limits.max_contacts.used` | `integer` | Aktueller Verbrauch. |
| `data.limits.max_contacts.remaining` | `integer` | Noch verfügbar (-1 = unbegrenzt). |
| `data.limits.emails_per_day` | `object` | Limit mit Verbrauch und Rest. |
| `data.limits.emails_per_day.label` | `string` | Name des Limits. |
| `data.limits.emails_per_day.limit` | `integer` | Limit des Tarifs (-1 = unbegrenzt). |
| `data.limits.emails_per_day.used` | `integer` | Aktueller Verbrauch. |
| `data.limits.emails_per_day.remaining` | `integer` | Noch verfügbar (-1 = unbegrenzt). |
| `data.features` | `object` | Funktionen des Tarifs (true = freigeschaltet). |
| `data.features.api` | `object` | Funktion mit Name und Freischaltung. |
| `data.features.api.label` | `string` | Name der Funktion. |
| `data.features.api.enabled` | `boolean` | Funktion ist freigeschaltet. |
| `data.features.ai` | `object` | Funktion mit Name und Freischaltung. |
| `data.features.ai.label` | `string` | Name der Funktion. |
| `data.features.ai.enabled` | `boolean` | Funktion ist freigeschaltet. |

```json
{
  "data": {
    "plan": "pro",
    "plan_label": "Pro",
    "limits": {
      "max_contacts": {
        "label": "Kontakte",
        "limit": -1,
        "used": 1250,
        "remaining": -1
      },
      "emails_per_day": {
        "label": "E-Mails pro Tag",
        "limit": -1,
        "used": 3,
        "remaining": -1
      }
    },
    "features": {
      "api": {
        "label": "API & Webhooks",
        "enabled": true
      },
      "ai": {
        "label": "KI-Assistent",
        "enabled": true
      }
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-lists"></a>
### E-Mail-Listen

Listen anlegen, ändern, löschen und Mitglieder verwalten.

<a id="obj-list"></a>
#### Das Listen-Objekt

Eine E-Mail-Liste mit Mitgliederzahlen und den öffentlichen Anmelde-Adressen.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `name` | `string` | Name der Liste (eindeutig in Deinem Konto). |
| `description` | `string` | Interne Beschreibung. |
| `color` | `string` | Farbe als Hex-Wert (#RRGGBB). |
| `double_optin` | `boolean` | Neue Anmeldungen müssen per Bestätigungsmail bestätigt werden (Double-Opt-in). |
| `welcome_soap_id` | `integer` | Seifenoper, die beim Eintrag automatisch startet, oder null. |
| `doi_redirect_url` | `string` | Seite nach erfolgreicher Bestätigung oder null. |
| `redirect_domains` | `array<string>` | Erlaubte Weiterleitungs-Domains für success_url/error_url des Anmeldeformulars (leer = alle). |
| `public_token` | `string` | Öffentlicher Token für Anmeldeformulare (POST /subscribe/{token}). |
| `subscribe_url` | `string` | Öffentliche Anmelde-URL – ohne API-Schlüssel nutzbar. |
| `form_url` | `string` | Fertiges HTML-Anmeldeformular zum Verlinken oder Einbetten (iframe). |
| `counts` | `object` | Mitglieder nach Status. |
| `counts.subscribed` | `integer` | Aktive Mitglieder. |
| `counts.pending` | `integer` | Noch unbestätigte Anmeldungen (Double-Opt-in). |
| `counts.unsubscribed` | `integer` | Abgemeldete Mitglieder. |
| `counts.bounced` | `integer` | Unzustellbare Adressen. |
| `counts.total` | `integer` | Alle Mitglieder. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 3,
  "name": "Kartenherz Funnel",
  "description": "Leads aus dem Funnel auf kartenherz.com",
  "color": "#5B3DF5",
  "double_optin": true,
  "welcome_soap_id": 7,
  "doi_redirect_url": "https://kartenherz.com/danke",
  "redirect_domains": ["kartenherz.com"],
  "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
  "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
  "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
  "counts": {
    "subscribed": 1180,
    "pending": 24,
    "unsubscribed": 41,
    "bounced": 5,
    "total": 1250
  },
  "created_at": "2026-09-01T10:00:00+02:00",
  "updated_at": "2026-10-01T08:30:00+02:00"
}
```

<a id="lists-list"></a>
#### Listen auflisten

`GET /api/v1/lists`

Scope: `read` · paginiert

Alle E-Mail-Listen mit Mitgliederzahlen je Status (subscribed, pending, unsubscribed, bounced, total), dem öffentlichen Anmelde-Token und der Anmelde-URL.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `q` | `string` | nein | Suche im Namen. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl https://persolz.com/api/v1/lists \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Listen-Objekt](#obj-list)

```json
{
  "data": [
    {
      "id": 3,
      "name": "Kartenherz Funnel",
      "description": "Leads aus dem Funnel auf kartenherz.com",
      "color": "#5B3DF5",
      "double_optin": true,
      "welcome_soap_id": 7,
      "doi_redirect_url": "https://kartenherz.com/danke",
      "redirect_domains": ["kartenherz.com"],
      "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
      "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
      "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
      "counts": {
        "subscribed": 1180,
        "pending": 24,
        "unsubscribed": 41,
        "bounced": 5,
        "total": 1250
      },
      "created_at": "2026-09-01T10:00:00+02:00",
      "updated_at": "2026-10-01T08:30:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="lists-create"></a>
#### Liste anlegen

`POST /api/v1/lists`

Scope: `write` · unterstützt `Idempotency-Key`

Legt eine neue Liste an. Im Free-Tarif ist nur eine Liste möglich (→ 402 limit_reached).

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name der Liste (eindeutig, max. 190 Zeichen). |
| `description` | `string` | nein | Beschreibung (intern). |
| `color` | `string` | nein | Farbe als Hex-Wert (#RRGGBB). |
| `double_optin` | `boolean` | nein | Neue Anmeldungen über Formular/API müssen per Bestätigungsmail bestätigt werden. |
| `welcome_soap_id` | `integer` | nein | Seifenoper, die beim Eintrag automatisch startet (null = keine). |
| `doi_redirect_url` | `string` | nein | Seite nach erfolgreicher Bestätigung (https://…). |
| `redirect_domains` | `array` | nein | Erlaubte Weiterleitungs-Domains für success_url/error_url des öffentlichen Anmeldeformulars (inkl. Subdomains; Liste oder kommagetrennt, max. 20). Leer = jedes http(s)-Ziel ist erlaubt. |

```bash
curl -X POST https://persolz.com/api/v1/lists \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke"
  }'
```

**Antwort 201** – Liste angelegt · [Listen-Objekt](#obj-list)

```json
{
  "data": {
    "id": 3,
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "color": "#5B3DF5",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke",
    "redirect_domains": ["kartenherz.com"],
    "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
    "counts": {
      "subscribed": 1180,
      "pending": 24,
      "unsubscribed": 41,
      "bounced": 5,
      "total": 1250
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="lists-get"></a>
#### Liste abrufen

`GET /api/v1/lists/{id}`

Scope: `read`

Eine Liste mit Zahlen, Token und Anmelde-URLs.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/lists/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Listen-Objekt](#obj-list)

```json
{
  "data": {
    "id": 3,
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "color": "#5B3DF5",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke",
    "redirect_domains": ["kartenherz.com"],
    "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
    "counts": {
      "subscribed": 1180,
      "pending": 24,
      "unsubscribed": 41,
      "bounced": 5,
      "total": 1250
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="lists-update"></a>
#### Liste ändern

`PATCH /api/v1/lists/{id}`

Scope: `write`

Ändert nur die übergebenen Felder.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | nein | Name der Liste (eindeutig, max. 190 Zeichen). |
| `description` | `string` | nein | Beschreibung (intern). |
| `color` | `string` | nein | Farbe als Hex-Wert (#RRGGBB). |
| `double_optin` | `boolean` | nein | Neue Anmeldungen über Formular/API müssen per Bestätigungsmail bestätigt werden. |
| `welcome_soap_id` | `integer` | nein | Seifenoper, die beim Eintrag automatisch startet (null = keine). |
| `doi_redirect_url` | `string` | nein | Seite nach erfolgreicher Bestätigung (https://…). |
| `redirect_domains` | `array` | nein | Erlaubte Weiterleitungs-Domains für success_url/error_url des öffentlichen Anmeldeformulars (inkl. Subdomains; Liste oder kommagetrennt, max. 20). Leer = jedes http(s)-Ziel ist erlaubt. |

```bash
curl -X PATCH https://persolz.com/api/v1/lists/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Leads aus Funnel und Messe",
    "double_optin": false
  }'
```

**Antwort 200** – OK · [Listen-Objekt](#obj-list)

```json
{
  "data": {
    "id": 3,
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "color": "#5B3DF5",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke",
    "redirect_domains": ["kartenherz.com"],
    "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
    "counts": {
      "subscribed": 1180,
      "pending": 24,
      "unsubscribed": 41,
      "bounced": 5,
      "total": 1250
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="lists-replace"></a>
#### Liste ersetzen

`PUT /api/v1/lists/{id}`

Scope: `write`

Setzt alle Felder neu (nicht übergebene optionale Felder werden geleert, double_optin wird false). „name“ ist Pflicht.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name der Liste (eindeutig, max. 190 Zeichen). |
| `description` | `string` | nein | Beschreibung (intern). |
| `color` | `string` | nein | Farbe als Hex-Wert (#RRGGBB). |
| `double_optin` | `boolean` | nein | Neue Anmeldungen über Formular/API müssen per Bestätigungsmail bestätigt werden. |
| `welcome_soap_id` | `integer` | nein | Seifenoper, die beim Eintrag automatisch startet (null = keine). |
| `doi_redirect_url` | `string` | nein | Seite nach erfolgreicher Bestätigung (https://…). |
| `redirect_domains` | `array` | nein | Erlaubte Weiterleitungs-Domains für success_url/error_url des öffentlichen Anmeldeformulars (inkl. Subdomains; Liste oder kommagetrennt, max. 20). Leer = jedes http(s)-Ziel ist erlaubt. |

```bash
curl -X PUT https://persolz.com/api/v1/lists/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke"
  }'
```

**Antwort 200** – OK · [Listen-Objekt](#obj-list)

```json
{
  "data": {
    "id": 3,
    "name": "Kartenherz Funnel",
    "description": "Leads aus dem Funnel auf kartenherz.com",
    "color": "#5B3DF5",
    "double_optin": true,
    "welcome_soap_id": 7,
    "doi_redirect_url": "https://kartenherz.com/danke",
    "redirect_domains": ["kartenherz.com"],
    "public_token": "9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "subscribe_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c",
    "form_url": "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form",
    "counts": {
      "subscribed": 1180,
      "pending": 24,
      "unsubscribed": 41,
      "bounced": 5,
      "total": 1250
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="lists-delete"></a>
#### Liste löschen

`DELETE /api/v1/lists/{id}`

Scope: `write`

Löscht die Liste. Kontakte bleiben erhalten – mit ?delete_contacts=true werden Kontakte gelöscht, die in keiner anderen Liste sind.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `delete_contacts` | `boolean` | nein | Kontakte ohne andere Liste ebenfalls löschen. |

```bash
curl -X DELETE https://persolz.com/api/v1/lists/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="lists-contacts"></a>
#### Mitglieder einer Liste

`GET /api/v1/lists/{id}/contacts`

Scope: `read` · paginiert

Kontakte der Liste, filterbar nach Status und Suchbegriff.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | subscribed \| pending \| unsubscribed \| bounced Werte: `subscribed`, `pending`, `unsubscribed`, `bounced`. |
| `q` | `string` | nein | Suche in Name, E-Mail, Telefon, Ort. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/lists/42/contacts?status=subscribed" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Kontakt-Objekt](#obj-contact)

```json
{
  "data": [
    {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="lists-addcontacts"></a>
#### Kontakte zur Liste hinzufügen

`POST /api/v1/lists/{id}/contacts`

Scope: `write` · unterstützt `Idempotency-Key`

Fügt bestehende Kontakte (contact_ids) hinzu und/oder legt Kontakte per E-Mail an bzw. aktualisiert sie (contacts – Upsert, max. 1000). Bei Double-Opt-in-Listen erhalten neue Kontakte eine Bestätigungsmail. Automationen der Liste (Willkommens-Seifenoper) starten für aktive Kontakte.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `contact_ids` | `array<integer>` | nein | IDs bestehender Kontakte. |
| `contacts` | `array<object>` | nein | Kontakte als Objekte (email Pflicht, weitere Felder wie bei POST /contacts). |

```bash
curl -X POST https://persolz.com/api/v1/lists/42/contacts \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_ids": [42, 43],
    "contacts": [
      {
        "email": "ben@beispiel.de",
        "first_name": "Ben",
        "tags": ["messe"]
      }
    ]
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.added` | `integer` | Neu in die Liste aufgenommene Kontakte. |
| `data.created` | `integer` | Neu angelegte Kontakte. |
| `data.updated` | `integer` | Aktualisierte bestehende Kontakte. |
| `data.skipped` | `integer` | Übersprungene Einträge (z. B. bereits Mitglied). |
| `data.failed` | `integer` | Fehlgeschlagene Einträge (Details in errors). |
| `data.errors` | `array` | Fehler je Eintrag (leer, wenn alles geklappt hat). |
| `data.list` | `object` | Die Liste mit den neuen Mitgliederzahlen. |
| `data.list.id` | `integer` | ID der Liste. |
| `data.list.counts` | `object` | Mitglieder nach Status. |
| `data.list.counts.subscribed` | `integer` | Aktive Mitglieder. |
| `data.list.counts.pending` | `integer` | Noch unbestätigte Anmeldungen (Double-Opt-in). |
| `data.list.counts.unsubscribed` | `integer` | Abgemeldete Mitglieder. |
| `data.list.counts.bounced` | `integer` | Unzustellbare Adressen. |
| `data.list.counts.total` | `integer` | Alle Mitglieder. |

```json
{
  "data": {
    "added": 2,
    "created": 1,
    "updated": 0,
    "skipped": 0,
    "failed": 0,
    "errors": [],
    "list": {
      "id": 3,
      "counts": {
        "subscribed": 1182,
        "pending": 25,
        "unsubscribed": 41,
        "bounced": 5,
        "total": 1253
      }
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="lists-removecontact"></a>
#### Kontakt aus Liste entfernen

`DELETE /api/v1/lists/{id}/contacts/{contactId}`

Scope: `write`

Entfernt die Mitgliedschaft (der Kontakt selbst bleibt erhalten).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |
| `contactId` | `integer` | ja | ID des Kontakts |

```bash
curl -X DELETE https://persolz.com/api/v1/lists/42/contacts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Entfernt

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-contacts"></a>
### Kontakte

Kontakte suchen, anlegen (Upsert per E-Mail), ändern, abmelden, Massenabgleich und Aktivitäten.

Ein Kontakt ist eindeutig über seine E-Mail-Adresse. `POST /contacts` ist deshalb ein **Upsert**: neu anlegen oder vorhandenen Kontakt ergänzen. Für viele Kontakte auf einmal nutze `POST /contacts/bulk`.

<a id="obj-contact"></a>
#### Das Kontakt-Objekt

Ein Kontakt mit Stammdaten, Tags, eigenen Feldern und seinen Listen.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `email` | `string` | E-Mail-Adresse (eindeutig je Konto, kleingeschrieben). |
| `first_name` | `string` | Vorname. |
| `last_name` | `string` | Nachname. |
| `address` | `string` | Straße und Hausnummer. |
| `zip` | `string` | Postleitzahl. |
| `city` | `string` | Ort. |
| `phone` | `string` | Telefonnummer. |
| `note` | `string` | Notiz zum Kontakt. |
| `tags` | `array<string>` | Tags des Kontakts. |
| `status` | `string` | subscribed (aktiv), pending (unbestätigt), unsubscribed (abgemeldet) oder bounced (unzustellbar). |
| `source` | `string` | Herkunft: manual, import, form, api, digistore, copecart … |
| `custom_fields` | `object` | Eigene Felder als Objekt – jeder Schlüssel ist auch als Platzhalter nutzbar ({{kundennummer}}). |
| `custom_fields.kundennummer` | `string` | Wert eines eigenen Feldes (hier ein Beispiel). |
| `doi_confirmed_at` | `string · null` | Zeitpunkt der Double-Opt-in-Bestätigung oder null. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |
| `lists` | `array<object>` | Listen, in denen der Kontakt ist. |
| `lists[].id` | `integer` | ID der Liste. |
| `lists[].name` | `string` | Name der Liste. |
| `lists[].color` | `string` | Farbe der Liste. |

```json
{
  "id": 42,
  "email": "anna@beispiel.de",
  "first_name": "Anna",
  "last_name": "Schmidt",
  "address": "Lindenweg 3",
  "zip": "10115",
  "city": "Berlin",
  "phone": "+49 30 1234567",
  "note": "Kam über den Kartenherz-Funnel",
  "tags": ["funnel", "kartenherz"],
  "status": "subscribed",
  "source": "api",
  "custom_fields": {
    "kundennummer": "K-1001"
  },
  "doi_confirmed_at": null,
  "created_at": "2026-10-09T14:05:00+02:00",
  "updated_at": "2026-10-09T14:05:00+02:00",
  "lists": [
    {
      "id": 3,
      "name": "Kartenherz Funnel",
      "color": "#5B3DF5"
    }
  ]
}
```

<a id="contacts-list"></a>
#### Kontakte suchen

`GET /api/v1/contacts`

Scope: `read` · paginiert

Kontakte mit Listen, gefiltert und sortiert. Für eine Synchronisation eignet sich updated_after + sort=updated_asc.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | nein | Exakte E-Mail-Adresse. |
| `q` | `string` | nein | Suche in Name, E-Mail, Telefon, Ort, PLZ. |
| `list_id` | `integer` | nein | Nur Mitglieder dieser Liste. |
| `status` | `string` | nein | subscribed \| pending \| unsubscribed \| bounced Werte: `subscribed`, `pending`, `unsubscribed`, `bounced`. |
| `tag` | `string` | nein | Nur Kontakte mit diesem Tag. |
| `source` | `string` | nein | Herkunft: manual, import, form, api, digistore, copecart … |
| `created_after` | `string` | nein | Angelegt nach (ISO 8601 oder YYYY-MM-DD). |
| `updated_after` | `string` | nein | Geändert nach (ISO 8601). |
| `sort` | `string` | nein | created_desc (Standard) \| created_asc \| updated_desc \| updated_asc \| email_asc \| name_asc Werte: `created_desc`, `created_asc`, `updated_desc`, `updated_asc`, `email_asc`, `name_asc`. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/contacts?list_id=3&status=subscribed&per_page=50" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Kontakt-Objekt](#obj-contact)

```json
{
  "data": [
    {
      "id": 42,
      "email": "anna@beispiel.de",
      "first_name": "Anna",
      "last_name": "Schmidt",
      "address": "Lindenweg 3",
      "zip": "10115",
      "city": "Berlin",
      "phone": "+49 30 1234567",
      "note": "Kam über den Kartenherz-Funnel",
      "tags": ["funnel", "kartenherz"],
      "status": "subscribed",
      "source": "api",
      "custom_fields": {
        "kundennummer": "K-1001"
      },
      "doi_confirmed_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00",
      "lists": [
        {
          "id": 3,
          "name": "Kartenherz Funnel",
          "color": "#5B3DF5"
        }
      ]
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="contacts-upsert"></a>
#### Kontakt anlegen oder aktualisieren (Upsert)

`POST /api/v1/contacts`

Scope: `write` · unterstützt `Idempotency-Key`

Legt einen Kontakt an oder aktualisiert ihn anhand der E-Mail-Adresse (nur übergebene, nicht leere Werte werden übernommen, Tags ergänzt, Listen hinzugefügt). Double-Opt-in-Listen lösen eine Bestätigungsmail aus (Status pending). Optional startet soap_sequence_id eine Seifenoper (nur für aktive Kontakte). 201 = neu angelegt, 200 = aktualisiert.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | ja | E-Mail-Adresse (eindeutig je Konto, wird kleingeschrieben). |
| `first_name` | `string` | nein | Vorname (Alias: vorname, firstName). |
| `last_name` | `string` | nein | Nachname (Alias: nachname, lastName). |
| `name` | `string` | nein | Voller Name – wird aufgeteilt, wenn Vor-/Nachname fehlen. |
| `phone` | `string` | nein | Telefon. |
| `address` | `string` | nein | Straße und Hausnummer. |
| `zip` | `string` | nein | PLZ. |
| `city` | `string` | nein | Ort. |
| `note` | `string` | nein | Notiz (max. 5000 Zeichen). |
| `tags` | `array<string>` | nein | Tags (Array oder kommagetrennt). Beim Upsert werden Tags ergänzt. |
| `custom_fields` | `object` | nein | Eigene Felder (max. 50 Schlüssel a–z, 0–9, _ und -) – auch als Platzhalter {{schluessel}} nutzbar. |
| `status` | `string` | nein | subscribed \| pending \| unsubscribed. Abgemeldete Adressen werden nur mit consent=true reaktiviert. Werte: `subscribed`, `pending`, `unsubscribed`. |
| `list_ids` | `array<integer>` | nein | Listen, in die der Kontakt aufgenommen wird (Double-Opt-in der Liste wird beachtet). Aliase: lists (IDs oder Listen-Objekte wie in der Antwort), list_id. |
| `consent` | `boolean` | nein | Bestätigt eine neue Einwilligung (nötig, um abgemeldete Kontakte wieder anzumelden). |
| `doi` | `boolean` | nein | Erzwingt eine Bestätigungsmail (Double-Opt-in), auch wenn die Liste es nicht verlangt. |
| `soap_sequence_id` | `integer` | nein | Seifenoper, in die der Kontakt aufgenommen wird. |
| `soap_start_date` | `string` | nein | Startdatum der Seifenoper (YYYY-MM-DD, Standard heute). |

```bash
curl -X POST https://persolz.com/api/v1/contacts \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "tags": ["funnel"],
    "list_ids": [3],
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "soap_sequence_id": 7
  }'
```

**Antwort 201** – Angelegt (200 bei Aktualisierung) · [Kontakt-Objekt](#obj-contact)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.result` | `object` | Was beim Upsert passiert ist. |
| `data.result.created` | `boolean` | Der Kontakt wurde neu angelegt. |
| `data.result.updated` | `boolean` | Ein bestehender Kontakt wurde aktualisiert. |
| `data.result.status` | `string` | Status des Kontakts nach dem Upsert. |
| `data.result.doi_sent` | `boolean` | Eine Bestätigungsmail (Double-Opt-in) wurde gesendet. |
| `data.result.message` | `string · null` | Hinweis zum Ergebnis oder null. |
| `data.result.soap_enrollment` | `object` | Ergebnis der Aufnahme in die Seifenoper (nur mit soap_sequence_id) oder null. |
| `data.result.soap_enrollment.ok` | `boolean` | Aufnahme erfolgreich. |
| `data.result.soap_enrollment.enrollment_id` | `integer` | ID der Teilnahme. |
| `data.result.soap_enrollment.error` | `string · null` | Grund, falls die Aufnahme nicht möglich war (z. B. Double-Opt-in noch offen). |

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ],
    "result": {
      "created": true,
      "updated": false,
      "status": "subscribed",
      "doi_sent": false,
      "message": null,
      "soap_enrollment": {
        "ok": true,
        "enrollment_id": 501,
        "error": null
      }
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-lookup"></a>
#### Kontakt per E-Mail finden

`GET /api/v1/contacts/lookup`

Scope: `read`

Liefert den Kontakt zur E-Mail-Adresse oder 404.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | nein | E-Mail-Adresse. |

```bash
curl https://persolz.com/api/v1/contacts/lookup \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-bulk"></a>
#### Massenabgleich (max. 1000)

`POST /api/v1/contacts/bulk`

Scope: `write` · unterstützt `Idempotency-Key`

action=upsert: bis zu 1000 Kontakte anlegen/aktualisieren (optional in list_ids, mit tags). action=delete: Kontakte per ids oder emails löschen. Automationen (Seifenopern, Webhooks) laufen nur mit run_automations=true.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `action` | `string` | ja | upsert \| delete Werte: `upsert`, `delete`. |
| `contacts` | `array<object>` | nein | Für upsert: Kontakte (Felder wie POST /contacts). |
| `ids` | `array<integer>` | nein | Für delete: Kontakt-IDs. |
| `emails` | `array<string>` | nein | Für delete: E-Mail-Adressen. |
| `list_ids` | `array<integer>` | nein | Für upsert: Listen für alle Kontakte. |
| `tags` | `array<string>` | nein | Für upsert: Tags für alle Kontakte. |
| `duplicate` | `string` | nein | Für upsert: update (Standard) oder skip für bestehende Adressen. Werte: `update`, `skip`. |
| `run_automations` | `boolean` | nein | Webhooks und Listen-Automationen auslösen (Standard false). |

```bash
curl -X POST https://persolz.com/api/v1/contacts/bulk \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "upsert",
    "list_ids": [3],
    "tags": ["messe-2026"],
    "contacts": [
      {
        "email": "a@beispiel.de",
        "first_name": "Alex"
      },
      {
        "email": "b@beispiel.de",
        "vorname": "Bea"
      }
    ]
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.action` | `string` | Ausgeführte Aktion: upsert oder delete. |
| `data.created` | `integer` | Neu angelegte Kontakte. |
| `data.updated` | `integer` | Aktualisierte Kontakte. |
| `data.skipped` | `integer` | Übersprungene Einträge (z. B. duplicate=skip). |
| `data.unchanged` | `integer` | Unverändert (keine neuen Daten). |
| `data.failed` | `integer` | Fehlgeschlagene Einträge (Details in errors). |
| `data.errors` | `array` | Fehler je Eintrag (leer, wenn alles geklappt hat). |

```json
{
  "data": {
    "action": "upsert",
    "created": 2,
    "updated": 0,
    "skipped": 0,
    "unchanged": 0,
    "failed": 0,
    "errors": []
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 413 `payload_too_large`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-get"></a>
#### Kontakt abrufen

`GET /api/v1/contacts/{id}`

Scope: `read`

Ein Kontakt mit Listen, Engagement-Kennzahlen und Seifenopern-Teilnahmen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/contacts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.engagement` | `object` | Engagement-Kennzahlen des Kontakts. |
| `data.engagement.sent` | `integer` | Erhaltene E-Mails. |
| `data.engagement.opened` | `integer` | Davon geöffnet. |
| `data.engagement.clicked` | `integer` | Davon mit Klick. |
| `data.engagement.failed` | `integer` | Nicht zustellbare E-Mails. |
| `data.engagement.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.engagement.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.engagement.last_activity_at` | `string (Datum/Zeit)` | Letzte Öffnung oder letzter Klick. |
| `data.soap_enrollments` | `array<object>` | Teilnahmen an Seifenopern. |
| `data.soap_enrollments[].id` | `integer` | ID der Teilnahme. |
| `data.soap_enrollments[].sequence_id` | `integer` | ID der Seifenoper. |
| `data.soap_enrollments[].sequence_name` | `string` | Name der Seifenoper. |
| `data.soap_enrollments[].status` | `string` | Status der Teilnahme: active, paused, completed, cancelled, unsubscribed. |
| `data.soap_enrollments[].current_position` | `integer` | Zuletzt erreichter Tag (Position). |
| `data.soap_enrollments[].start_date` | `string (Datum)` | Startdatum. |

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ],
    "engagement": {
      "sent": 14,
      "opened": 11,
      "clicked": 4,
      "failed": 0,
      "open_rate": 0.7857,
      "click_rate": 0.2857,
      "last_activity_at": "2026-10-09T14:06:12+02:00"
    },
    "soap_enrollments": [
      {
        "id": 501,
        "sequence_id": 7,
        "sequence_name": "Willkommen bei Kartenherz",
        "status": "active",
        "current_position": 2,
        "start_date": "2026-10-08"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="contacts-update"></a>
#### Kontakt ändern

`PATCH /api/v1/contacts/{id}`

Scope: `write`

Ändert nur die übergebenen Felder. list_ids gleicht die Listen exakt ab (nicht genannte Listen werden entfernt), add_list_ids / remove_list_ids ändern einzelne Mitgliedschaften. Zum Reaktivieren abgemeldeter Kontakte ist consent=true nötig.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | nein | E-Mail-Adresse (eindeutig je Konto, wird kleingeschrieben). |
| `first_name` | `string` | nein | Vorname (Alias: vorname, firstName). |
| `last_name` | `string` | nein | Nachname (Alias: nachname, lastName). |
| `name` | `string` | nein | Voller Name – wird aufgeteilt, wenn Vor-/Nachname fehlen. |
| `phone` | `string` | nein | Telefon. |
| `address` | `string` | nein | Straße und Hausnummer. |
| `zip` | `string` | nein | PLZ. |
| `city` | `string` | nein | Ort. |
| `note` | `string` | nein | Notiz (max. 5000 Zeichen). |
| `tags` | `array<string>` | nein | Tags (Array oder kommagetrennt). Beim Upsert werden Tags ergänzt. |
| `custom_fields` | `object` | nein | Eigene Felder (max. 50 Schlüssel a–z, 0–9, _ und -) – auch als Platzhalter {{schluessel}} nutzbar. |
| `status` | `string` | nein | subscribed \| pending \| unsubscribed. Abgemeldete Adressen werden nur mit consent=true reaktiviert. Werte: `subscribed`, `pending`, `unsubscribed`. |
| `list_ids` | `array<integer>` | nein | Listen, in die der Kontakt aufgenommen wird (Double-Opt-in der Liste wird beachtet). Aliase: lists (IDs oder Listen-Objekte wie in der Antwort), list_id. |
| `consent` | `boolean` | nein | Bestätigt eine neue Einwilligung (nötig, um abgemeldete Kontakte wieder anzumelden). |
| `doi` | `boolean` | nein | Erzwingt eine Bestätigungsmail (Double-Opt-in), auch wenn die Liste es nicht verlangt. |
| `add_list_ids` | `array<integer>` | nein | Listen hinzufügen. |
| `remove_list_ids` | `array<integer>` | nein | Aus Listen entfernen. |

```bash
curl -X PATCH https://persolz.com/api/v1/contacts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Anna-Lena",
    "tags": ["funnel", "kundin"],
    "add_list_ids": [4]
  }'
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-replace"></a>
#### Kontakt ersetzen

`PUT /api/v1/contacts/{id}`

Scope: `write`

Setzt alle Profilfelder neu: nicht übergebene Felder (Name, Adresse, Notiz, Tags, eigene Felder) werden geleert. „email“ ist Pflicht. Listen werden nur mit list_ids abgeglichen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | ja | E-Mail-Adresse (eindeutig je Konto, wird kleingeschrieben). |
| `first_name` | `string` | nein | Vorname (Alias: vorname, firstName). |
| `last_name` | `string` | nein | Nachname (Alias: nachname, lastName). |
| `name` | `string` | nein | Voller Name – wird aufgeteilt, wenn Vor-/Nachname fehlen. |
| `phone` | `string` | nein | Telefon. |
| `address` | `string` | nein | Straße und Hausnummer. |
| `zip` | `string` | nein | PLZ. |
| `city` | `string` | nein | Ort. |
| `note` | `string` | nein | Notiz (max. 5000 Zeichen). |
| `tags` | `array<string>` | nein | Tags (Array oder kommagetrennt). Beim Upsert werden Tags ergänzt. |
| `custom_fields` | `object` | nein | Eigene Felder (max. 50 Schlüssel a–z, 0–9, _ und -) – auch als Platzhalter {{schluessel}} nutzbar. |
| `status` | `string` | nein | subscribed \| pending \| unsubscribed. Abgemeldete Adressen werden nur mit consent=true reaktiviert. Werte: `subscribed`, `pending`, `unsubscribed`. |
| `list_ids` | `array<integer>` | nein | Listen, in die der Kontakt aufgenommen wird (Double-Opt-in der Liste wird beachtet). Aliase: lists (IDs oder Listen-Objekte wie in der Antwort), list_id. |
| `consent` | `boolean` | nein | Bestätigt eine neue Einwilligung (nötig, um abgemeldete Kontakte wieder anzumelden). |
| `doi` | `boolean` | nein | Erzwingt eine Bestätigungsmail (Double-Opt-in), auch wenn die Liste es nicht verlangt. |

```bash
curl -X PUT https://persolz.com/api/v1/contacts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt-Berg",
    "city": "Hamburg"
  }'
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-delete"></a>
#### Kontakt löschen

`DELETE /api/v1/contacts/{id}`

Scope: `write`

Löscht den Kontakt mit Listen-Mitgliedschaften und Seifenopern-Teilnahmen. Eine Abmeldung der Adresse bleibt als Sperre erhalten.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/contacts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="contacts-unsubscribe"></a>
#### Kontakt abmelden

`POST /api/v1/contacts/{id}/unsubscribe`

Scope: `write` · unterstützt `Idempotency-Key`

Meldet den Kontakt ab: Die Adresse wird gesperrt, offene Versände und Seifenopern werden gestoppt (Webhook contact.unsubscribed).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `reason` | `string` | nein | Grund (optional, max. 500 Zeichen). |

```bash
curl -X POST https://persolz.com/api/v1/contacts/42/unsubscribe \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Wunsch per Telefon"
  }'
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

```json
{
  "data": {
    "status": "unsubscribed",
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-resubscribe"></a>
#### Kontakt wieder anmelden

`POST /api/v1/contacts/{id}/resubscribe`

Scope: `write` · unterstützt `Idempotency-Key`

Hebt eine Abmeldung auf. Nur mit consent=true (Du bestätigst eine neue, nachweisbare Einwilligung). Mit doi=true wird stattdessen eine Bestätigungsmail gesendet.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `consent` | `boolean` | ja | Neue Einwilligung liegt vor. |
| `doi` | `boolean` | nein | Statt sofortiger Anmeldung eine Bestätigungsmail senden. |

```bash
curl -X POST https://persolz.com/api/v1/contacts/42/resubscribe \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "consent": true
  }'
```

**Antwort 200** – OK · [Kontakt-Objekt](#obj-contact)

```json
{
  "data": {
    "id": 42,
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "last_name": "Schmidt",
    "address": "Lindenweg 3",
    "zip": "10115",
    "city": "Berlin",
    "phone": "+49 30 1234567",
    "note": "Kam über den Kartenherz-Funnel",
    "tags": ["funnel", "kartenherz"],
    "status": "subscribed",
    "source": "api",
    "custom_fields": {
      "kundennummer": "K-1001"
    },
    "doi_confirmed_at": null,
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00",
    "lists": [
      {
        "id": 3,
        "name": "Kartenherz Funnel",
        "color": "#5B3DF5"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="contacts-activity"></a>
#### Aktivitäten eines Kontakts

`GET /api/v1/contacts/{id}/activity`

Scope: `read`

Zeitstrahl (neueste zuerst): erhaltene, geöffnete und geklickte Mails, Abmeldung, Seifenopern, Termine, Listen, Double-Opt-in – plus Engagement-Kennzahlen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `limit` | `integer` | nein | Max. Einträge (1–200, Standard 80). |

```bash
curl https://persolz.com/api/v1/contacts/42/activity \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.engagement` | `object` | Engagement-Kennzahlen des Kontakts. |
| `data.engagement.sent` | `integer` | Erhaltene E-Mails. |
| `data.engagement.opened` | `integer` | Davon geöffnet. |
| `data.engagement.clicked` | `integer` | Davon mit Klick. |
| `data.engagement.failed` | `integer` | Nicht zustellbare E-Mails. |
| `data.engagement.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.engagement.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.engagement.last_activity_at` | `string (Datum/Zeit)` | Letzte Öffnung oder letzter Klick. |
| `data.items` | `array<object>` | Einträge, neueste zuerst. |
| `data.items[].type` | `string` | Art: sent, opened, clicked, unsubscribed, soap, appointment, list, doi … |
| `data.items[].title` | `string` | Überschrift des Eintrags. |
| `data.items[].body` | `string` | Details (z. B. Betreff der Mail). |
| `data.items[].time` | `string (Datum/Zeit)` | Zeitpunkt (ISO 8601). |

```json
{
  "data": {
    "engagement": {
      "sent": 14,
      "opened": 11,
      "clicked": 4,
      "failed": 0,
      "open_rate": 0.7857,
      "click_rate": 0.2857,
      "last_activity_at": "2026-10-09T14:06:12+02:00"
    },
    "items": [
      {
        "type": "opened",
        "title": "E-Mail geöffnet",
        "body": "„Was mein Kaffeebecher über Marketing weiß“",
        "time": "2026-10-09T14:05:00+02:00"
      },
      {
        "type": "list",
        "title": "Zur Liste „Kartenherz Funnel“ hinzugefügt",
        "body": "",
        "time": "2026-10-01T09:00:00+02:00"
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-unsubscribes"></a>
### Abmeldungen

Gesperrte Adressen (Abmeldeliste) lesen, ergänzen und aufheben.

Die Abmeldeliste schützt Adressen dauerhaft vor Werbung – auch wenn der Kontakt gelöscht wird. Eine Sperre aufzuheben ist nur mit neuer, nachweisbarer Einwilligung erlaubt (`consent=true`).

<a id="obj-unsubscribe"></a>
#### Das Abmelde-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `email` | `string` | Gesperrte E-Mail-Adresse. |
| `contact_id` | `integer` | ID des Kontakts oder null (Sperre ohne Kontakt). |
| `source` | `string` | Quelle: link, one_click, manual, api, import, bounce oder complaint. |
| `source_label` | `string` | Herkunft in Worten. |
| `reason` | `string` | Angegebener Grund oder null. |
| `campaign_id` | `integer` | Kampagne, über die abgemeldet wurde, oder null. |
| `list_id` | `integer · null` | Liste, aus der abgemeldet wurde, oder null. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Abmeldung. |

```json
{
  "id": 77,
  "email": "max@beispiel.de",
  "contact_id": 51,
  "source": "link",
  "source_label": "Abmeldelink",
  "reason": "Zu viele Mails",
  "campaign_id": 812,
  "list_id": null,
  "created_at": "2026-10-08T19:12:00+02:00"
}
```

<a id="unsubscribes-list"></a>
#### Abmeldungen auflisten

`GET /api/v1/unsubscribes`

Scope: `read` · paginiert

Alle gesperrten Adressen, neueste zuerst.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | nein | Exakte E-Mail-Adresse. |
| `q` | `string` | nein | Suche in E-Mail und Grund. |
| `source` | `string` | nein | link \| one_click \| manual \| api \| import \| bounce \| complaint |
| `created_after` | `string` | nein | Abgemeldet nach (ISO 8601 / YYYY-MM-DD). |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/unsubscribes?created_after=2026-10-01" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Abmelde-Objekt](#obj-unsubscribe)

```json
{
  "data": [
    {
      "id": 77,
      "email": "max@beispiel.de",
      "contact_id": 51,
      "source": "link",
      "source_label": "Abmeldelink",
      "reason": "Zu viele Mails",
      "campaign_id": 812,
      "list_id": null,
      "created_at": "2026-10-08T19:12:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="unsubscribes-create"></a>
#### Adresse sperren

`POST /api/v1/unsubscribes`

Scope: `write` · unterstützt `Idempotency-Key`

Trägt eine Adresse in die Abmeldeliste ein (auch ohne Kontakt). Ein vorhandener Kontakt wird abgemeldet.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | ja | E-Mail-Adresse. |
| `reason` | `string` | nein | Grund (optional). |

```bash
curl -X POST https://persolz.com/api/v1/unsubscribes \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "max@beispiel.de",
    "reason": "Beschwerde per Mail"
  }'
```

**Antwort 201** – Gesperrt · [Abmelde-Objekt](#obj-unsubscribe)

```json
{
  "data": {
    "source": "api",
    "source_label": "API",
    "campaign_id": null,
    "id": 77,
    "email": "max@beispiel.de",
    "contact_id": 51,
    "reason": "Zu viele Mails",
    "list_id": null,
    "created_at": "2026-10-08T19:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="unsubscribes-delete-by-email"></a>
#### Sperre per E-Mail aufheben

`DELETE /api/v1/unsubscribes`

Scope: `write`

Wie „Sperre aufheben“, aber über die E-Mail-Adresse statt der ID. Nur mit ?consent=true – Du bestätigst eine neue, nachweisbare Einwilligung.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | nein | Gesperrte E-Mail-Adresse (URL-kodiert). |
| `consent` | `boolean` | nein | Neue Einwilligung liegt vor (Pflicht). |

```bash
curl -X DELETE https://persolz.com/api/v1/unsubscribes \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Aufgehoben

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="unsubscribes-delete"></a>
#### Sperre aufheben

`DELETE /api/v1/unsubscribes/{id}`

Scope: `write`

Hebt die Abmeldung auf und reaktiviert den Kontakt. Nur mit ?consent=true – Du bestätigst eine neue, nachweisbare Einwilligung.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `consent` | `boolean` | nein | Neue Einwilligung liegt vor (Pflicht). |

```bash
curl -X DELETE https://persolz.com/api/v1/unsubscribes/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Aufgehoben

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="ref-emails"></a>
### E-Mails

Einzel-E-Mails (z. B. Transaktions- oder Follow-up-Mails) versenden oder planen.

Einzel-E-Mails sind Kampagnen vom Typ `email` – ideal für Transaktions- und Follow-up-Mails. Newsletter und E-Mails teilen sich das Kampagnen-Objekt.

<a id="obj-campaign"></a>
#### Das Kampagnen-Objekt

Jeder Versand ist eine Kampagne: Einzel-E-Mails, Newsletter, Seinfeld-Mails und Seifenopern-Tage.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `type` | `string` | Versandart: email, newsletter, seinfeld oder soap. |
| `type_label` | `string` | Typ in Worten (Deutsch). |
| `name` | `string` | Interner Name. |
| `subject` | `string` | Betreff. |
| `preheader` | `string` | Vorschautext (erscheint im Postfach neben dem Betreff). |
| `status` | `string` | draft, scheduled, queued, sending, sent, paused, cancelled, failed oder active (Seifenopern-Tag). |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `audience` | `object` | Empfänger: {all, lists, contacts, emails} oder null. |
| `audience.all` | `boolean` | An alle aktiven Kontakte senden. |
| `audience.lists` | `array<integer>` | IDs der Empfänger-Listen. |
| `audience.contacts` | `array` | IDs einzelner Kontakte. |
| `audience.emails` | `array` | Einzelne E-Mail-Adressen. |
| `audience_label` | `string` | Empfänger in Worten, z. B. „1 Liste“. |
| `smtp_account_id` | `integer` | SMTP-Konto für den Versand oder null (= Standardkonto des Moduls). |
| `attachment_ids` | `array` | IDs der Anhänge aus der Anhang-Cloud. |
| `include_default_attachments` | `boolean` | Standard-Anhänge werden zusätzlich mitgesendet. |
| `track_opens` | `boolean` | Öffnungen werden gemessen. |
| `track_clicks` | `boolean` | Klicks werden gemessen. |
| `ab_test_id` | `integer · null` | ID des verknüpften A/B-Tests oder null. |
| `scheduled_at` | `string · null` | Geplanter Versandzeitpunkt (ISO 8601) oder null. |
| `started_at` | `string (Datum/Zeit)` | Beginn des Versands oder null. |
| `finished_at` | `string (Datum/Zeit)` | Ende des Versands oder null. |
| `stats` | `object` | Kennzahlen des Versands. |
| `stats.recipients` | `integer` | Empfänger gesamt. |
| `stats.sent` | `integer` | Versendet. |
| `stats.failed` | `integer` | Fehlgeschlagen. |
| `stats.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `stats.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `stats.unsubs` | `integer` | Abmeldungen. |
| `stats.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `stats.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 812,
  "type": "newsletter",
  "type_label": "Newsletter",
  "name": "Herbst-Aktion",
  "subject": "Anna, das hier ist nur für Dich",
  "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
  "status": "sent",
  "status_label": "Gesendet",
  "audience": {
    "all": false,
    "lists": [3],
    "contacts": [],
    "emails": []
  },
  "audience_label": "1 Liste",
  "smtp_account_id": 1,
  "attachment_ids": [],
  "include_default_attachments": false,
  "track_opens": true,
  "track_clicks": true,
  "ab_test_id": null,
  "scheduled_at": null,
  "started_at": "2026-10-09T09:00:00+02:00",
  "finished_at": "2026-10-09T09:04:31+02:00",
  "stats": {
    "recipients": 1250,
    "sent": 1248,
    "failed": 2,
    "unique_opens": 702,
    "unique_clicks": 133,
    "unsubs": 3,
    "open_rate": 0.5625,
    "click_rate": 0.1066
  },
  "created_at": "2026-10-08T17:20:00+02:00",
  "updated_at": "2026-10-09T09:04:31+02:00"
}
```

<a id="emails-send"></a>
#### E-Mail senden oder planen

`POST /api/v1/emails/send`

Scope: `send` · Tarif-Funktion: `email` · unterstützt `Idempotency-Key`

Versendet eine E-Mail an einzelne Adressen, Kontakte oder Listen – sofort oder geplant (scheduled_at). Inhalt als html, text oder aus einer Vorlage (template_key aus der Bibliothek bzw. template_id einer eigenen Vorlage). Platzhalter wie {{vorname}} werden je Empfänger ersetzt, „placeholders“ ersetzt zusätzlich eigene Platzhalter für alle Empfänger. Abgemeldete und unbestätigte Kontakte werden automatisch ausgeschlossen. Antwort 202: die Mail ist in der Warteschlange (Kampagnen-ID zum Nachverfolgen).

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `to` | `string|array` | ja | Empfänger: eine E-Mail, kommagetrennte E-Mails, ein Array aus E-Mails und/oder Kontakt-IDs. |
| `list_ids` | `array<integer>` | nein | Zusätzlich alle aktiven Mitglieder dieser Listen. |
| `subject` | `string` | ja | Betreff (Pflicht, außer die Vorlage liefert einen). Platzhalter erlaubt. |
| `html` | `string` | nein | HTML-Inhalt (max. 2 MB). |
| `text` | `string` | nein | Alternativ: reiner Text (wird in eine schlichte HTML-Mail verpackt). |
| `template_key` | `string` | nein | Bibliotheks-Vorlage, z. B. email-07. |
| `template_id` | `integer` | nein | Eigene Vorlage. |
| `preheader` | `string` | nein | Vorschautext. |
| `placeholders` | `object` | nein | Eigene Platzhalter für alle Empfänger, z. B. {"bestellnummer":"4711"} → {{bestellnummer}}. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto (Standard: Modul- bzw. Standardkonto). |
| `attachment_ids` | `array<integer>` | nein | Anhänge aus der Anhang-Cloud. |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge mitsenden (Standard false). |
| `scheduled_at` | `string` | nein | Geplanter Versand (ISO 8601, max. 1 Jahr). Leer = sofort. |
| `name` | `string` | nein | Interner Name (Standard: Betreff). |
| `from_name` | `string` | nein | Abweichender Absendername. |
| `reply_to` | `string` | nein | Abweichende Antwort-Adresse. |
| `track_opens` | `boolean` | nein | Öffnungen messen (Standard true). |
| `track_clicks` | `boolean` | nein | Klicks messen (Standard true). |

```bash
curl -X POST https://persolz.com/api/v1/emails/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["anna@beispiel.de"],
    "subject": "Deine Bestellung #{{bestellnummer}}",
    "html": "<p>Hallo {{vorname|Freund}},</p><p>danke für Deine Bestellung {{bestellnummer}}!</p>",
    "placeholders": {
      "bestellnummer": "4711"
    },
    "attachment_ids": [9]
  }'
```

**Antwort 202** – In der Warteschlange bzw. geplant

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.campaign_id` | `integer` | ID der E-Mail – damit fragst Du den Status ab (GET /emails/{id}). |
| `data.status` | `string` | sending (wird versendet) oder scheduled (geplant). |
| `data.scheduled_at` | `string · null` | Geplanter Versandzeitpunkt (ISO 8601) oder null. |
| `data.recipients` | `integer` | Empfänger gesamt. |
| `data.queued` | `integer` | In die Warteschlange gestellte E-Mails. |
| `data.sent` | `integer` | Bereits versendet (Rest folgt automatisch). |
| `data.failed` | `integer` | Fehlgeschlagen. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "campaign_id": 813,
    "status": "sending",
    "scheduled_at": null,
    "recipients": 1,
    "queued": 1,
    "sent": 1,
    "failed": 0,
    "message": "Versand abgeschlossen: 1 E-Mail verschickt."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="emails-list"></a>
#### E-Mails auflisten

`GET /api/v1/emails`

Scope: `read` · Tarif-Funktion: `email` · paginiert

Einzel-E-Mails (Kampagnen vom Typ email).

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | all \| sent \| scheduled \| drafts Werte: `all`, `sent`, `scheduled`, `drafts`. |
| `q` | `string` | nein | Suche in Betreff/Name. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/emails?status=sent" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": [
    {
      "id": 812,
      "type": "email",
      "type_label": "E-Mail",
      "name": "Herbst-Aktion",
      "subject": "Anna, das hier ist nur für Dich",
      "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
      "status": "sent",
      "status_label": "Gesendet",
      "audience": {
        "all": false,
        "lists": [3],
        "contacts": [],
        "emails": []
      },
      "audience_label": "1 Liste",
      "smtp_account_id": 1,
      "attachment_ids": [],
      "include_default_attachments": false,
      "track_opens": true,
      "track_clicks": true,
      "ab_test_id": null,
      "scheduled_at": null,
      "started_at": "2026-10-09T09:00:00+02:00",
      "finished_at": "2026-10-09T09:04:31+02:00",
      "stats": {
        "recipients": 1250,
        "sent": 1248,
        "failed": 2,
        "unique_opens": 702,
        "unique_clicks": 133,
        "unsubs": 3,
        "open_rate": 0.5625,
        "click_rate": 0.1066
      },
      "created_at": "2026-10-08T17:20:00+02:00",
      "updated_at": "2026-10-09T09:04:31+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="emails-get"></a>
#### E-Mail abrufen

`GET /api/v1/emails/{id}`

Scope: `read` · Tarif-Funktion: `email`

Eine E-Mail inklusive HTML und Versandstatistik.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/emails/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Kampagnen-Objekt](#obj-campaign)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.html` | `string` | HTML-Inhalt mit Platzhaltern. |

```json
{
  "data": {
    "id": 812,
    "type": "email",
    "type_label": "E-Mail",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
    "status": "sent",
    "status_label": "Gesendet",
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "audience_label": "1 Liste",
    "smtp_account_id": 1,
    "attachment_ids": [],
    "include_default_attachments": false,
    "track_opens": true,
    "track_clicks": true,
    "ab_test_id": null,
    "scheduled_at": null,
    "started_at": "2026-10-09T09:00:00+02:00",
    "finished_at": "2026-10-09T09:04:31+02:00",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "unique_opens": 702,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066
    },
    "created_at": "2026-10-08T17:20:00+02:00",
    "updated_at": "2026-10-09T09:04:31+02:00",
    "html": "<p>Hallo {{vorname}} …</p>"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-newsletters"></a>
### Newsletter

Newsletter als Entwurf anlegen, testen, versenden, planen und auswerten.

Ablauf: Entwurf anlegen → Testmail → senden oder planen. Newsletter verwenden das [Kampagnen-Objekt](#obj-campaign).

<a id="newsletters-list"></a>
#### Newsletter auflisten

`GET /api/v1/newsletters`

Scope: `read` · Tarif-Funktion: `newsletter` · paginiert

Alle Newsletter mit Status und Kennzahlen.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | all \| drafts \| scheduled \| sent Werte: `all`, `drafts`, `scheduled`, `sent`. |
| `q` | `string` | nein | Suche in Betreff/Name. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/newsletters?status=sent" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": [
    {
      "id": 812,
      "type": "newsletter",
      "type_label": "Newsletter",
      "name": "Herbst-Aktion",
      "subject": "Anna, das hier ist nur für Dich",
      "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
      "status": "sent",
      "status_label": "Gesendet",
      "audience": {
        "all": false,
        "lists": [3],
        "contacts": [],
        "emails": []
      },
      "audience_label": "1 Liste",
      "smtp_account_id": 1,
      "attachment_ids": [],
      "include_default_attachments": false,
      "track_opens": true,
      "track_clicks": true,
      "ab_test_id": null,
      "scheduled_at": null,
      "started_at": "2026-10-09T09:00:00+02:00",
      "finished_at": "2026-10-09T09:04:31+02:00",
      "stats": {
        "recipients": 1250,
        "sent": 1248,
        "failed": 2,
        "unique_opens": 702,
        "unique_clicks": 133,
        "unsubs": 3,
        "open_rate": 0.5625,
        "click_rate": 0.1066
      },
      "created_at": "2026-10-08T17:20:00+02:00",
      "updated_at": "2026-10-09T09:04:31+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="newsletters-create"></a>
#### Newsletter-Entwurf anlegen

`POST /api/v1/newsletters`

Scope: `write` · Tarif-Funktion: `newsletter` · unterstützt `Idempotency-Key`

Legt einen Newsletter als Entwurf an (es wird nichts versendet). Vollständigkeit (Betreff, Inhalt, Empfänger, SMTP) wird erst beim Versand geprüft.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | nein | Interner Name. |
| `subject` | `string` | nein | Betreff (zum Versand Pflicht). |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `text` | `string` | nein | Alternativ reiner Text. |
| `template_key` | `string` | nein | Bibliotheks-Vorlage (übernimmt HTML, Betreff, Preheader, falls nicht angegeben). |
| `template_id` | `integer` | nein | Eigene Vorlage. |
| `audience` | `object` | nein | Empfänger: {"all":false,"lists":[3],"contacts":[42],"emails":["x@y.de"]}. |
| `list_ids` | `array<integer>` | nein | Kurzform für audience.lists. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto. |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge mitsenden. |
| `from_name` | `string` | nein | Abweichender Absendername. |
| `reply_to` | `string` | nein | Abweichende Antwort-Adresse. |
| `track_opens` | `boolean` | nein | Öffnungen messen. |
| `track_clicks` | `boolean` | nein | Klicks messen. |

```bash
curl -X POST https://persolz.com/api/v1/newsletters \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "template_key": "newsletter-01",
    "list_ids": [3]
  }'
```

**Antwort 201** – Entwurf angelegt · [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": {
    "status": "draft",
    "status_label": "Entwurf",
    "started_at": null,
    "finished_at": null,
    "id": 812,
    "type": "newsletter",
    "type_label": "Newsletter",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "audience_label": "1 Liste",
    "smtp_account_id": 1,
    "attachment_ids": [],
    "include_default_attachments": false,
    "track_opens": true,
    "track_clicks": true,
    "ab_test_id": null,
    "scheduled_at": null,
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "unique_opens": 702,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066
    },
    "created_at": "2026-10-08T17:20:00+02:00",
    "updated_at": "2026-10-09T09:04:31+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="newsletters-get"></a>
#### Newsletter abrufen

`GET /api/v1/newsletters/{id}`

Scope: `read` · Tarif-Funktion: `newsletter`

Ein Newsletter inklusive HTML und Kennzahlen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/newsletters/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Kampagnen-Objekt](#obj-campaign)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.html` | `string` | HTML-Inhalt mit Platzhaltern. |

```json
{
  "data": {
    "id": 812,
    "type": "newsletter",
    "type_label": "Newsletter",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
    "status": "sent",
    "status_label": "Gesendet",
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "audience_label": "1 Liste",
    "smtp_account_id": 1,
    "attachment_ids": [],
    "include_default_attachments": false,
    "track_opens": true,
    "track_clicks": true,
    "ab_test_id": null,
    "scheduled_at": null,
    "started_at": "2026-10-09T09:00:00+02:00",
    "finished_at": "2026-10-09T09:04:31+02:00",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "unique_opens": 702,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066
    },
    "created_at": "2026-10-08T17:20:00+02:00",
    "updated_at": "2026-10-09T09:04:31+02:00",
    "html": "<!doctype html><html>…</html>"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="newsletters-update"></a>
#### Newsletter ändern

`PATCH /api/v1/newsletters/{id}`

Scope: `write` · Tarif-Funktion: `newsletter`

Ändert einen Entwurf oder geplanten Newsletter (versendete sind gesperrt → 409).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | nein | Interner Name. |
| `subject` | `string` | nein | Betreff (zum Versand Pflicht). |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `text` | `string` | nein | Alternativ reiner Text. |
| `template_key` | `string` | nein | Bibliotheks-Vorlage (übernimmt HTML, Betreff, Preheader, falls nicht angegeben). |
| `template_id` | `integer` | nein | Eigene Vorlage. |
| `audience` | `object` | nein | Empfänger: {"all":false,"lists":[3],"contacts":[42],"emails":["x@y.de"]}. |
| `list_ids` | `array<integer>` | nein | Kurzform für audience.lists. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto. |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge mitsenden. |
| `from_name` | `string` | nein | Abweichender Absendername. |
| `reply_to` | `string` | nein | Abweichende Antwort-Adresse. |
| `track_opens` | `boolean` | nein | Öffnungen messen. |
| `track_clicks` | `boolean` | nein | Klicks messen. |

```bash
curl -X PATCH https://persolz.com/api/v1/newsletters/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Neuer Betreff",
    "list_ids": [3, 4]
  }'
```

**Antwort 200** – OK · [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": {
    "id": 812,
    "type": "newsletter",
    "type_label": "Newsletter",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
    "status": "sent",
    "status_label": "Gesendet",
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "audience_label": "1 Liste",
    "smtp_account_id": 1,
    "attachment_ids": [],
    "include_default_attachments": false,
    "track_opens": true,
    "track_clicks": true,
    "ab_test_id": null,
    "scheduled_at": null,
    "started_at": "2026-10-09T09:00:00+02:00",
    "finished_at": "2026-10-09T09:04:31+02:00",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "unique_opens": 702,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066
    },
    "created_at": "2026-10-08T17:20:00+02:00",
    "updated_at": "2026-10-09T09:04:31+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="newsletters-delete"></a>
#### Newsletter löschen

`DELETE /api/v1/newsletters/{id}`

Scope: `write` · Tarif-Funktion: `newsletter`

Löscht den Newsletter samt Statistik. Laufende Versände müssen zuerst abgebrochen werden (409).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/newsletters/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="newsletters-send"></a>
#### Newsletter jetzt senden

`POST /api/v1/newsletters/{id}/send`

Scope: `send` · Tarif-Funktion: `newsletter` · unterstützt `Idempotency-Key`

Prüft Vollständigkeit und Tageslimit, stellt alle Empfänger in die Warteschlange und verschickt einen ersten Schwung. Der Rest folgt über den Cron.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/newsletters/42/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

**Antwort 202** – Versand gestartet

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.id` | `integer` | ID des Newsletters. |
| `data.status` | `string` | Neuer Status: sending (Versand läuft), scheduled (geplant) oder draft (Entwurf). |
| `data.queued` | `integer` | In die Warteschlange gestellte E-Mails. |
| `data.sent` | `integer` | Bereits versendet (der Rest folgt automatisch über den Cron). |
| `data.failed` | `integer` | Fehlgeschlagen. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "id": 812,
    "status": "sending",
    "queued": 1250,
    "sent": 150,
    "failed": 0,
    "message": "Versand gestartet: 150 von 1.250 E-Mails sind raus, der Rest folgt automatisch."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="newsletters-schedule"></a>
#### Newsletter planen

`POST /api/v1/newsletters/{id}/schedule`

Scope: `send` · Tarif-Funktion: `newsletter` · unterstützt `Idempotency-Key`

Plant den Versand für einen Zeitpunkt in der Zukunft (max. 1 Jahr).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `scheduled_at` | `string` | ja | Zeitpunkt (ISO 8601). |

```bash
curl -X POST https://persolz.com/api/v1/newsletters/42/schedule \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-10-12T09:00:00+02:00"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.id` | `integer` | ID des Newsletters. |
| `data.status` | `string` | Neuer Status: sending (Versand läuft), scheduled (geplant) oder draft (Entwurf). |
| `data.scheduled_at` | `string (Datum/Zeit)` | Geplanter Versandzeitpunkt (ISO 8601) oder null. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "id": 812,
    "status": "scheduled",
    "scheduled_at": "2026-10-12T09:00:00+02:00",
    "message": "Newsletter geplant für 12.10.2026 09:00 Uhr – wir senden pünktlich."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="newsletters-cancel"></a>
#### Versand abbrechen

`POST /api/v1/newsletters/{id}/cancel`

Scope: `send` · Tarif-Funktion: `newsletter` · unterstützt `Idempotency-Key`

Geplante Newsletter werden wieder zum Entwurf, laufende Versände werden gestoppt.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/newsletters/42/cancel \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.id` | `integer` | ID des Newsletters. |
| `data.status` | `string` | Neuer Status: sending (Versand läuft), scheduled (geplant) oder draft (Entwurf). |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "id": 812,
    "status": "draft",
    "message": "Versand abgebrochen – der Newsletter liegt wieder bei Deinen Entwürfen."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="newsletters-test"></a>
#### Testmail senden

`POST /api/v1/newsletters/{id}/test`

Scope: `send` · Tarif-Funktion: `newsletter` · unterstützt `Idempotency-Key`

Sendet den aktuellen Stand als Testmail (Betreff mit „[TEST]“, ohne Tracking) an bis zu 5 Adressen. Ohne „to“ an Deine Konto-Adresse.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `to` | `string|array` | nein | Bis zu 5 Adressen. |
| `contact_id` | `integer` | nein | Kontakt für die Personalisierung (Standard: Beispielkontakt „Max“). |

```bash
curl -X POST https://persolz.com/api/v1/newsletters/42/test \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "du@beispiel.de"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.sent_to` | `array<string>` | Empfänger der Testmail. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "sent_to": ["du@beispiel.de"],
    "message": "Testmail an du@beispiel.de gesendet."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="newsletters-stats"></a>
#### Newsletter-Statistik

`GET /api/v1/newsletters/{id}/stats`

Scope: `read` · Tarif-Funktion: `newsletter`

Zahlen und Quoten (Öffnungen, Klicks, Abmeldungen, Bounces), Top-Links und Geräte.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/newsletters/42/stats \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.id` | `integer` | ID des Newsletters. |
| `data.status` | `string` | Status des Newsletters. |
| `data.stats` | `object` | Kennzahlen. |
| `data.stats.recipients` | `integer` | Empfänger gesamt. |
| `data.stats.sent` | `integer` | Versendet. |
| `data.stats.failed` | `integer` | Fehlgeschlagen. |
| `data.stats.pending` | `integer` | Noch nicht versendet. |
| `data.stats.held` | `integer` | Wartet auf den Gewinner eines A/B-Tests. |
| `data.stats.skipped` | `integer` | Übersprungen (z. B. inzwischen abgemeldet). |
| `data.stats.cancelled` | `integer` | Abgebrochen. |
| `data.stats.opens` | `integer` | Öffnungen gesamt (inkl. mehrfacher Öffnungen). |
| `data.stats.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `data.stats.clicks` | `integer` | Klicks gesamt. |
| `data.stats.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `data.stats.unsubs` | `integer` | Abmeldungen. |
| `data.stats.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.stats.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.stats.ctor` | `number` | Click-to-Open-Rate von 0 bis 1 (eindeutige Klicks / eindeutige Öffnungen). |
| `data.stats.unsub_rate` | `number` | Abmeldequote von 0 bis 1. |
| `data.stats.bounce_rate` | `number` | Bounce-Quote von 0 bis 1. |
| `data.stats.failed_rate` | `number` | Anteil fehlgeschlagener Zustellungen von 0 bis 1. |
| `data.stats.progress` | `number` | Fortschritt des Versands von 0 bis 1. |
| `data.links` | `array<object>` | Meistgeklickte Links. |
| `data.links[].url` | `string` | Ziel-URL des Links. |
| `data.links[].label` | `string` | Linktext. |
| `data.links[].clicks` | `integer` | Klicks gesamt. |
| `data.links[].unique_clicks` | `integer` | Empfänger, die geklickt haben. |
| `data.devices` | `object` | Öffnungen nach Gerät. |
| `data.devices.desktop` | `integer` | Öffnungen am Desktop. |
| `data.devices.mobile` | `integer` | Öffnungen auf dem Smartphone. |
| `data.devices.tablet` | `integer` | Öffnungen auf dem Tablet. |

```json
{
  "data": {
    "id": 812,
    "status": "sent",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "pending": 0,
      "held": 0,
      "skipped": 0,
      "cancelled": 0,
      "opens": 980,
      "unique_opens": 702,
      "clicks": 190,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066,
      "ctor": 0.1895,
      "unsub_rate": 0.0024,
      "bounce_rate": 0.0016,
      "failed_rate": 0.0016,
      "progress": 1.0
    },
    "links": [
      {
        "url": "https://kartenherz.com/angebot",
        "label": "Jetzt ansehen",
        "clicks": 140,
        "unique_clicks": 118
      }
    ],
    "devices": {
      "desktop": 312,
      "mobile": 365,
      "tablet": 25
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-seinfeld"></a>
### Seinfeld-Mails

Tägliche Story-Mails: Einstellungen, Themen, Entwürfe, Planung und KI-Generierung.

Seinfeld-Mails brauchen die Tarif-Funktion `seinfeld`. Eine Schritt-für-Schritt-Anleitung findest Du unter [Seinfeld-Mails planen](#seinfeld-planen).

<a id="obj-seinfeld-settings"></a>
#### Das Seinfeld-Einstellungs-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `enabled` | `boolean` | Seinfeld-Mails sind aktiv. |
| `frequency` | `string` | Rhythmus: daily (täglich), weekdays (Mo–Fr), custom (eigene Wochentage) oder interval (alle n Tage). |
| `weekdays` | `array<integer>` | Versandtage bei custom: 1 (Mo) bis 7 (So). |
| `every_n_days` | `integer` | Abstand in Tagen bei interval. |
| `anchor` | `string (Datum)` | Startdatum des Rhythmus bei interval. |
| `send_time` | `string` | Versandzeit (HH:MM, Europe/Berlin). |
| `smtp_account_id` | `integer · null` | SMTP-Konto für den Versand oder null (= Standardkonto des Moduls). |
| `audience` | `object` | Empfänger: {all, lists, contacts, emails} oder null. |
| `audience.all` | `boolean` | An alle aktiven Kontakte senden. |
| `audience.lists` | `array<integer>` | IDs der Empfänger-Listen. |
| `audience.contacts` | `array` | IDs einzelner Kontakte. |
| `audience.emails` | `array` | Einzelne E-Mail-Adressen. |
| `auto_generate` | `boolean` | Die KI schreibt automatisch Entwürfe für freie Termine. |
| `auto_send` | `boolean` | KI-Entwürfe werden ohne Freigabe automatisch geplant. |
| `plan_ahead_days` | `integer` | So viele Tage im Voraus werden Entwürfe vorbereitet. |
| `ai_model` | `string` | KI-Modell: sonnet (beste Qualität) oder haiku (schnell & günstig). |
| `persona` | `object` | Persona, mit der die KI schreibt. |
| `persona.name` | `string` | Name des Absenders in den Geschichten. |
| `persona.character` | `string` | Charakter. |
| `persona.background` | `string` | Hintergrund. |
| `persona.style` | `string` | Schreibstil. |
| `audience_desc` | `string` | Beschreibung Deiner Zielgruppe (für die KI). |
| `offer_name` | `string` | Name Deines Angebots. |
| `offer_url` | `string` | Link zum Angebot. |
| `offer_desc` | `string` | Kurzbeschreibung des Angebots. |
| `tone` | `string` | Tonalität. |
| `signature` | `string` | Grußformel unter jeder Mail. |
| `template_key` | `string` | Design-Vorlage aus der Vorlagen-Bibliothek (z. B. seinfeld-01) oder null. |
| `continuity_mode` | `string` | Story-Kontinuität: z. B. arcs (zusammenhängende Story-Bögen). |
| `include_default_attachments` | `boolean` | Standard-Anhänge werden zusätzlich mitgesendet. |
| `rhythm` | `string` | Rhythmus in Worten. |
| `streak` | `integer` | Aktuelle Serie: Versandtage in Folge ohne Lücke. |

```json
{
  "enabled": true,
  "frequency": "weekdays",
  "weekdays": [1, 2, 3, 4, 5],
  "every_n_days": 2,
  "anchor": "2026-10-01",
  "send_time": "09:00",
  "smtp_account_id": null,
  "audience": {
    "all": false,
    "lists": [3],
    "contacts": [],
    "emails": []
  },
  "auto_generate": true,
  "auto_send": false,
  "plan_ahead_days": 3,
  "ai_model": "sonnet",
  "persona": {
    "name": "Daniel",
    "character": "Humorvoller Kartenmacher",
    "background": "Seit 10 Jahren Illustrator",
    "style": "locker, Du-Form"
  },
  "audience_desc": "Menschen, die gern Karten verschicken",
  "offer_name": "Kartenset-Abo",
  "offer_url": "https://kartenherz.com/abo",
  "offer_desc": "Jeden Monat 5 handgezeichnete Karten",
  "tone": "unterhaltsam",
  "signature": "Dein Daniel",
  "template_key": "seinfeld-01",
  "continuity_mode": "arcs",
  "include_default_attachments": false,
  "rhythm": "Montag bis Freitag um 09:00 Uhr",
  "streak": 12
}
```

<a id="obj-topic"></a>
#### Das Themen-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `topic` | `string` | Das Thema (Stichwort oder Satz). |
| `notes` | `string` | Notizen für die KI oder für Dich. |
| `priority` | `boolean` | Thema mit Stern – wird zuerst verwendet. |
| `status` | `string` | open (offen), used (verwendet) oder skipped (übersprungen). |
| `used_at` | `string · null` | Zeitpunkt, an dem das Thema verwendet wurde. |
| `email_id` | `integer · null` | Seinfeld-Mail, die das Thema verwendet, oder null. |
| `sort_order` | `integer` | Reihenfolge. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 15,
  "topic": "Der verlorene Schlüssel",
  "notes": "Bezug zu Ordnungssystemen",
  "priority": true,
  "status": "open",
  "used_at": null,
  "email_id": null,
  "sort_order": 3,
  "created_at": "2026-10-01T09:00:00+02:00"
}
```

<a id="obj-seinfeld-email"></a>
#### Das Seinfeld-Mail-Objekt

Eine Seinfeld-Mail mit Story-Gedächtnis (Titel, Zusammenfassung, Stichworte).

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `subject` | `string` | Betreff. |
| `preheader` | `string` | Vorschautext (erscheint im Postfach neben dem Betreff). |
| `html` | `string` | HTML-Inhalt. |
| `status` | `string` | draft (Entwurf), pending (KI-Entwurf zur Freigabe), scheduled (geplant), sending, sent, failed oder cancelled. |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `ai_generated` | `boolean` | Von der KI geschrieben. |
| `ai_model` | `string` | Verwendetes KI-Modell oder null. |
| `topic_id` | `integer` | Verwendetes Thema oder null. |
| `arc_id` | `integer · null` | Story-Bogen oder null. |
| `episode_no` | `string · null` | Folge im Story-Bogen oder null. |
| `story_title` | `string` | Titel der Geschichte (Story-Gedächtnis gegen Wiederholungen). |
| `story_summary` | `string` | Kurze Zusammenfassung (Story-Gedächtnis). |
| `keywords` | `array<string>` | Stichworte. |
| `template_key` | `string` | Design-Vorlage aus der Vorlagen-Bibliothek (z. B. seinfeld-01) oder null. |
| `scheduled_at` | `string (Datum/Zeit)` | Geplanter Versandzeitpunkt (ISO 8601) oder null. |
| `sent_at` | `string · null` | Zeitpunkt des Versands oder null. |
| `campaign_id` | `integer` | Kampagne des Versands (nach dem Planen) oder null. |
| `smtp_account_id` | `integer · null` | SMTP-Konto für den Versand oder null (= Standardkonto des Moduls). |
| `audience` | `object · null` | Abweichende Empfänger oder null (= Einstellungen). |
| `attachment_ids` | `array` | IDs der Anhänge aus der Anhang-Cloud. |
| `include_default_attachments` | `boolean` | Standard-Anhänge werden zusätzlich mitgesendet. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 120,
  "subject": "Was mein Kaffeebecher über Marketing weiß",
  "preheader": "Eine Geschichte über Gewohnheiten",
  "html": "<!doctype html><html>…</html>",
  "status": "scheduled",
  "status_label": "Geplant",
  "ai_generated": true,
  "ai_model": "claude-sonnet-5-5",
  "topic_id": 15,
  "arc_id": null,
  "episode_no": null,
  "story_title": "Der Kaffeebecher",
  "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
  "keywords": ["kaffee", "gewohnheit"],
  "template_key": "seinfeld-01",
  "scheduled_at": "2026-10-10T09:00:00+02:00",
  "sent_at": null,
  "campaign_id": 901,
  "smtp_account_id": null,
  "audience": null,
  "attachment_ids": [],
  "include_default_attachments": false,
  "created_at": "2026-10-09T07:00:00+02:00",
  "updated_at": "2026-10-09T08:12:00+02:00"
}
```

<a id="seinfeld-settings"></a>
#### Seinfeld-Einstellungen

`GET /api/v1/seinfeld/settings`

Scope: `read` · Tarif-Funktion: `seinfeld`

Rhythmus, Versandzeit, Empfänger, KI-Persona, Angebot und Automatik-Optionen – plus aktuelle Serie (Streak).

```bash
curl https://persolz.com/api/v1/seinfeld/settings \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Seinfeld-Einstellungs-Objekt](#obj-seinfeld-settings)

```json
{
  "data": {
    "enabled": true,
    "frequency": "weekdays",
    "weekdays": [1, 2, 3, 4, 5],
    "every_n_days": 2,
    "anchor": "2026-10-01",
    "send_time": "09:00",
    "smtp_account_id": null,
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "auto_generate": true,
    "auto_send": false,
    "plan_ahead_days": 3,
    "ai_model": "sonnet",
    "persona": {
      "name": "Daniel",
      "character": "Humorvoller Kartenmacher",
      "background": "Seit 10 Jahren Illustrator",
      "style": "locker, Du-Form"
    },
    "audience_desc": "Menschen, die gern Karten verschicken",
    "offer_name": "Kartenset-Abo",
    "offer_url": "https://kartenherz.com/abo",
    "offer_desc": "Jeden Monat 5 handgezeichnete Karten",
    "tone": "unterhaltsam",
    "signature": "Dein Daniel",
    "template_key": "seinfeld-01",
    "continuity_mode": "arcs",
    "include_default_attachments": false,
    "rhythm": "Montag bis Freitag um 09:00 Uhr",
    "streak": 12
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="seinfeld-savesettings"></a>
#### Seinfeld-Einstellungen speichern

`PUT /api/v1/seinfeld/settings`

Scope: `write` · Tarif-Funktion: `seinfeld`

Übernimmt die übergebenen Felder, alle anderen behalten ihren Wert.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `enabled` | `boolean` | nein | Seinfeld-Mails aktiv. |
| `frequency` | `string` | nein | daily \| weekdays \| custom \| interval Werte: `daily`, `weekdays`, `custom`, `interval`. |
| `weekdays` | `array<integer>` | nein | Bei custom: Wochentage 1 (Mo) – 7 (So). |
| `every_n_days` | `integer` | nein | Bei interval: Abstand in Tagen (2–30). |
| `send_time` | `string` | nein | Uhrzeit HH:MM. |
| `audience` | `object` | nein | Empfänger {all, lists, contacts, emails}. |
| `auto_generate` | `boolean` | nein | KI schreibt Entwürfe für freie Termine. |
| `auto_send` | `boolean` | nein | KI-Entwürfe ohne Freigabe automatisch planen. |
| `plan_ahead_days` | `integer` | nein | So viele Tage im Voraus vorbereiten (1–14). |
| `ai_model` | `string` | nein | sonnet \| haiku |
| `persona` | `object` | nein | {name, character, background, style}. |
| `offer_name` | `string` | nein | Angebot. |
| `offer_url` | `string` | nein | Link zum Angebot. |
| `tone` | `string` | nein | Tonalität. |
| `signature` | `string` | nein | Grußformel. |
| `template_key` | `string` | nein | Seinfeld-Vorlage. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto. |

```bash
curl -X PUT https://persolz.com/api/v1/seinfeld/settings \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "frequency": "weekdays",
    "send_time": "09:00",
    "audience": {
      "lists": [3]
    },
    "offer_url": "https://kartenherz.com/abo"
  }'
```

**Antwort 200** – OK · [Seinfeld-Einstellungs-Objekt](#obj-seinfeld-settings)

```json
{
  "data": {
    "enabled": true,
    "frequency": "weekdays",
    "weekdays": [1, 2, 3, 4, 5],
    "every_n_days": 2,
    "anchor": "2026-10-01",
    "send_time": "09:00",
    "smtp_account_id": null,
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "auto_generate": true,
    "auto_send": false,
    "plan_ahead_days": 3,
    "ai_model": "sonnet",
    "persona": {
      "name": "Daniel",
      "character": "Humorvoller Kartenmacher",
      "background": "Seit 10 Jahren Illustrator",
      "style": "locker, Du-Form"
    },
    "audience_desc": "Menschen, die gern Karten verschicken",
    "offer_name": "Kartenset-Abo",
    "offer_url": "https://kartenherz.com/abo",
    "offer_desc": "Jeden Monat 5 handgezeichnete Karten",
    "tone": "unterhaltsam",
    "signature": "Dein Daniel",
    "template_key": "seinfeld-01",
    "continuity_mode": "arcs",
    "include_default_attachments": false,
    "rhythm": "Montag bis Freitag um 09:00 Uhr",
    "streak": 12
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-topics"></a>
#### Themen auflisten

`GET /api/v1/seinfeld/topics`

Scope: `read` · Tarif-Funktion: `seinfeld` · paginiert

Die Themenliste (offen zuerst in manueller Reihenfolge).

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | all \| open \| used \| skipped Werte: `all`, `open`, `used`, `skipped`. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/seinfeld/topics?status=open" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Themen-Objekt](#obj-topic)

```json
{
  "data": [
    {
      "id": 15,
      "topic": "Der verlorene Schlüssel",
      "notes": "Bezug zu Ordnungssystemen",
      "priority": true,
      "status": "open",
      "used_at": null,
      "email_id": null,
      "sort_order": 3,
      "created_at": "2026-10-01T09:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="seinfeld-createtopic"></a>
#### Thema hinzufügen

`POST /api/v1/seinfeld/topics`

Scope: `write` · Tarif-Funktion: `seinfeld` · unterstützt `Idempotency-Key`

Fügt ein Thema hinzu – oder mehrere mit „topics“ (Array aus Texten oder {topic, note}). Doppelte Themen werden übersprungen.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `topic` | `string` | nein | Thema. |
| `notes` | `string` | nein | Notizen. |
| `priority` | `boolean` | nein | Mit Stern (wird zuerst genutzt). |
| `topics` | `array` | nein | Mehrere Themen auf einmal. |

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/topics \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "topic": "Der verlorene Schlüssel",
    "priority": true
  }'
```

**Antwort 201** – Angelegt · [Themen-Objekt](#obj-topic)

```json
{
  "data": {
    "id": 15,
    "topic": "Der verlorene Schlüssel",
    "notes": "Bezug zu Ordnungssystemen",
    "priority": true,
    "status": "open",
    "used_at": null,
    "email_id": null,
    "sort_order": 3,
    "created_at": "2026-10-01T09:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-updatetopic"></a>
#### Thema ändern

`PATCH /api/v1/seinfeld/topics/{id}`

Scope: `write` · Tarif-Funktion: `seinfeld`

Ändert Text, Notizen, Stern oder Status (open/skipped).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `topic` | `string` | nein | Thema. |
| `notes` | `string` | nein | Notizen. |
| `priority` | `boolean` | nein | Stern. |
| `status` | `string` | nein | open \| skipped |

```bash
curl -X PATCH https://persolz.com/api/v1/seinfeld/topics/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "priority": false
  }'
```

**Antwort 200** – OK · [Themen-Objekt](#obj-topic)

```json
{
  "data": {
    "id": 15,
    "topic": "Der verlorene Schlüssel",
    "notes": "Bezug zu Ordnungssystemen",
    "priority": true,
    "status": "open",
    "used_at": null,
    "email_id": null,
    "sort_order": 3,
    "created_at": "2026-10-01T09:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-deletetopic"></a>
#### Thema löschen

`DELETE /api/v1/seinfeld/topics/{id}`

Scope: `write` · Tarif-Funktion: `seinfeld`

Löscht das Thema.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/seinfeld/topics/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="seinfeld-emails"></a>
#### Seinfeld-Mails auflisten

`GET /api/v1/seinfeld/emails`

Scope: `read` · Tarif-Funktion: `seinfeld` · paginiert

Entwürfe, geplante und versendete Seinfeld-Mails.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | all \| pending (KI-Entwürfe zur Freigabe) \| draft \| planned (freigegeben & geplant, Alias „scheduled“) \| sent \| problems (fehlgeschlagen / abgebrochen) Werte: `all`, `pending`, `draft`, `planned`, `sent`, `problems`. |
| `q` | `string` | nein | Suche in Betreff und Story. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/seinfeld/emails?status=planned" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": [
    {
      "id": 120,
      "subject": "Was mein Kaffeebecher über Marketing weiß",
      "preheader": "Eine Geschichte über Gewohnheiten",
      "html": "<!doctype html><html>…</html>",
      "status": "scheduled",
      "status_label": "Geplant",
      "ai_generated": true,
      "ai_model": "claude-sonnet-5-5",
      "topic_id": 15,
      "arc_id": null,
      "episode_no": null,
      "story_title": "Der Kaffeebecher",
      "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
      "keywords": ["kaffee", "gewohnheit"],
      "template_key": "seinfeld-01",
      "scheduled_at": "2026-10-10T09:00:00+02:00",
      "sent_at": null,
      "campaign_id": 901,
      "smtp_account_id": null,
      "audience": null,
      "attachment_ids": [],
      "include_default_attachments": false,
      "created_at": "2026-10-09T07:00:00+02:00",
      "updated_at": "2026-10-09T08:12:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="seinfeld-createemail"></a>
#### Seinfeld-Mail anlegen

`POST /api/v1/seinfeld/emails`

Scope: `write` · Tarif-Funktion: `seinfeld` · unterstützt `Idempotency-Key`

Legt einen Entwurf an (wird nicht automatisch versendet – plane ihn mit /schedule).

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `scheduled_at` | `string` | nein | Geplanter Versand (ISO 8601); leer = nächster freier Termin beim Planen. |
| `topic_id` | `integer` | nein | Thema aus der Themenliste (wird als benutzt markiert). |
| `arc_id` | `integer` | nein | Story-Bogen. |
| `story_title` | `string` | nein | Titel der Geschichte (Story-Gedächtnis). |
| `story_summary` | `string` | nein | Kurze Zusammenfassung (Story-Gedächtnis gegen Wiederholungen). |
| `keywords` | `array<string>` | nein | Stichworte. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto (Standard: Einstellungen). |
| `audience` | `object` | nein | Abweichende Empfänger (Standard: Einstellungen). |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `template_key` | `string` | nein | Design-Vorlage. |

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/emails \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "html": "<p>Heute Morgen …</p>",
    "topic_id": 15
  }'
```

**Antwort 201** – Angelegt · [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": {
    "status": "draft",
    "status_label": "Entwurf",
    "campaign_id": null,
    "ai_generated": false,
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "preheader": "Eine Geschichte über Gewohnheiten",
    "html": "<!doctype html><html>…</html>",
    "ai_model": "claude-sonnet-5-5",
    "topic_id": 15,
    "arc_id": null,
    "episode_no": null,
    "story_title": "Der Kaffeebecher",
    "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
    "keywords": ["kaffee", "gewohnheit"],
    "template_key": "seinfeld-01",
    "scheduled_at": "2026-10-10T09:00:00+02:00",
    "sent_at": null,
    "smtp_account_id": null,
    "audience": null,
    "attachment_ids": [],
    "include_default_attachments": false,
    "created_at": "2026-10-09T07:00:00+02:00",
    "updated_at": "2026-10-09T08:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-getemail"></a>
#### Seinfeld-Mail abrufen

`GET /api/v1/seinfeld/emails/{id}`

Scope: `read` · Tarif-Funktion: `seinfeld`

Eine Seinfeld-Mail mit HTML und – nach dem Versand – Kennzahlen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/seinfeld/emails/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": {
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "preheader": "Eine Geschichte über Gewohnheiten",
    "html": "<!doctype html><html>…</html>",
    "status": "scheduled",
    "status_label": "Geplant",
    "ai_generated": true,
    "ai_model": "claude-sonnet-5-5",
    "topic_id": 15,
    "arc_id": null,
    "episode_no": null,
    "story_title": "Der Kaffeebecher",
    "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
    "keywords": ["kaffee", "gewohnheit"],
    "template_key": "seinfeld-01",
    "scheduled_at": "2026-10-10T09:00:00+02:00",
    "sent_at": null,
    "campaign_id": 901,
    "smtp_account_id": null,
    "audience": null,
    "attachment_ids": [],
    "include_default_attachments": false,
    "created_at": "2026-10-09T07:00:00+02:00",
    "updated_at": "2026-10-09T08:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="seinfeld-updateemail"></a>
#### Seinfeld-Mail ändern

`PATCH /api/v1/seinfeld/emails/{id}`

Scope: `write` · Tarif-Funktion: `seinfeld`

Ändert die übergebenen Felder. Versendete Mails sind gesperrt (409) – nur das Story-Gedächtnis bleibt änderbar. Geplante Mails werden neu eingeplant.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `scheduled_at` | `string` | nein | Geplanter Versand (ISO 8601); leer = nächster freier Termin beim Planen. |
| `topic_id` | `integer` | nein | Thema aus der Themenliste (wird als benutzt markiert). |
| `arc_id` | `integer` | nein | Story-Bogen. |
| `story_title` | `string` | nein | Titel der Geschichte (Story-Gedächtnis). |
| `story_summary` | `string` | nein | Kurze Zusammenfassung (Story-Gedächtnis gegen Wiederholungen). |
| `keywords` | `array<string>` | nein | Stichworte. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto (Standard: Einstellungen). |
| `audience` | `object` | nein | Abweichende Empfänger (Standard: Einstellungen). |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `template_key` | `string` | nein | Design-Vorlage. |

```bash
curl -X PATCH https://persolz.com/api/v1/seinfeld/emails/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Neuer Betreff"
  }'
```

**Antwort 200** – OK · [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": {
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "preheader": "Eine Geschichte über Gewohnheiten",
    "html": "<!doctype html><html>…</html>",
    "status": "scheduled",
    "status_label": "Geplant",
    "ai_generated": true,
    "ai_model": "claude-sonnet-5-5",
    "topic_id": 15,
    "arc_id": null,
    "episode_no": null,
    "story_title": "Der Kaffeebecher",
    "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
    "keywords": ["kaffee", "gewohnheit"],
    "template_key": "seinfeld-01",
    "scheduled_at": "2026-10-10T09:00:00+02:00",
    "sent_at": null,
    "campaign_id": 901,
    "smtp_account_id": null,
    "audience": null,
    "attachment_ids": [],
    "include_default_attachments": false,
    "created_at": "2026-10-09T07:00:00+02:00",
    "updated_at": "2026-10-09T08:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-deleteemail"></a>
#### Seinfeld-Mail löschen

`DELETE /api/v1/seinfeld/emails/{id}`

Scope: `write` · Tarif-Funktion: `seinfeld`

Löscht die Mail (das Thema wird wieder frei). Laufende Versände → 409.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/seinfeld/emails/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="seinfeld-scheduleemail"></a>
#### Seinfeld-Mail freigeben & planen

`POST /api/v1/seinfeld/emails/{id}/schedule`

Scope: `send` · Tarif-Funktion: `seinfeld` · unterstützt `Idempotency-Key`

Gibt die Mail frei und plant sie zum angegebenen Zeitpunkt, sonst zum geplanten Datum oder zum nächsten freien Termin im Rhythmus.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `scheduled_at` | `string` | nein | Zeitpunkt (ISO 8601), optional. |

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/emails/42/schedule \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-10-10T09:00:00+02:00"
  }'
```

**Antwort 200** – OK · [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": {
    "status": "scheduled",
    "scheduled_at": "2026-10-10T09:00:00+02:00",
    "campaign_id": 901,
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "preheader": "Eine Geschichte über Gewohnheiten",
    "html": "<!doctype html><html>…</html>",
    "status_label": "Geplant",
    "ai_generated": true,
    "ai_model": "claude-sonnet-5-5",
    "topic_id": 15,
    "arc_id": null,
    "episode_no": null,
    "story_title": "Der Kaffeebecher",
    "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
    "keywords": ["kaffee", "gewohnheit"],
    "template_key": "seinfeld-01",
    "sent_at": null,
    "smtp_account_id": null,
    "audience": null,
    "attachment_ids": [],
    "include_default_attachments": false,
    "created_at": "2026-10-09T07:00:00+02:00",
    "updated_at": "2026-10-09T08:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="seinfeld-unscheduleemail"></a>
#### Planung aufheben

`POST /api/v1/seinfeld/emails/{id}/unschedule`

Scope: `write` · Tarif-Funktion: `seinfeld` · unterstützt `Idempotency-Key`

Setzt eine geplante Mail zurück auf Entwurf (das Datum bleibt erhalten). Laufende Versände → 409.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/emails/42/unschedule \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Seinfeld-Mail-Objekt](#obj-seinfeld-email)

```json
{
  "data": {
    "status": "draft",
    "status_label": "Entwurf",
    "id": 120,
    "subject": "Was mein Kaffeebecher über Marketing weiß",
    "preheader": "Eine Geschichte über Gewohnheiten",
    "html": "<!doctype html><html>…</html>",
    "ai_generated": true,
    "ai_model": "claude-sonnet-5-5",
    "topic_id": 15,
    "arc_id": null,
    "episode_no": null,
    "story_title": "Der Kaffeebecher",
    "story_summary": "Warum kleine Rituale Vertrauen schaffen.",
    "keywords": ["kaffee", "gewohnheit"],
    "template_key": "seinfeld-01",
    "scheduled_at": "2026-10-10T09:00:00+02:00",
    "sent_at": null,
    "campaign_id": 901,
    "smtp_account_id": null,
    "audience": null,
    "attachment_ids": [],
    "include_default_attachments": false,
    "created_at": "2026-10-09T07:00:00+02:00",
    "updated_at": "2026-10-09T08:12:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="seinfeld-generate"></a>
#### Seinfeld-Mail mit KI schreiben

`POST /api/v1/seinfeld/generate`

Scope: `ai` · Tarif-Funktion: `seinfeld`, `ai` · unterstützt `Idempotency-Key`

Die KI schreibt eine Seinfeld-Mail aus dem nächsten Thema, einem Story-Bogen, einem bestimmten Thema oder Deiner Idee – mit Story-Gedächtnis gegen Wiederholungen. Mit persist=true wird ein Entwurf gespeichert (Antwort enthält email_id). Verbraucht KI-Guthaben.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `source` | `string` | nein | auto \| topic \| next_topic \| arc \| idea Werte: `auto`, `topic`, `next_topic`, `arc`, `idea`. |
| `topic_id` | `integer` | nein | Bei source=topic. |
| `arc_id` | `integer` | nein | Bei source=arc. |
| `idea` | `string` | nein | Bei source=idea: Deine Idee in ein paar Sätzen. |
| `model` | `string` | nein | sonnet (Qualität) \| haiku (schnell & günstig) |
| `date` | `string` | nein | Geplantes Versanddatum (für Bezüge im Text). |
| `persist` | `boolean` | nein | Als Entwurf speichern. |
| `scheduled_at` | `string` | nein | Bei persist: geplanter Zeitpunkt. |

```bash
curl -X POST https://persolz.com/api/v1/seinfeld/generate \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "idea",
    "idea": "Mein Hund hat heute die Post gefressen",
    "persist": true
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.subject` | `string` | Betreff. |
| `data.preheader` | `string` | Vorschautext (erscheint im Postfach neben dem Betreff). |
| `data.html` | `string` | HTML-Inhalt. |
| `data.story_title` | `string` | Titel der Geschichte (Story-Gedächtnis gegen Wiederholungen). |
| `data.story_summary` | `string` | Kurze Zusammenfassung (Story-Gedächtnis). |
| `data.keywords` | `array<string>` | Stichworte. |
| `data.model` | `string` | Verwendetes KI-Modell. |
| `data.cost_eur` | `number` | Kosten dieser KI-Anfrage in Euro (wird vom KI-Guthaben abgezogen). |
| `data.email_id` | `integer` | ID des gespeicherten Entwurfs (bei persist=true) oder null. |
| `data.warning` | `string · null` | Hinweis (z. B. wenn die KI gekürzt hat) oder null. |

```json
{
  "data": {
    "subject": "Der Tag, an dem Bruno die Post fraß",
    "preheader": "Und warum das gut fürs Geschäft war",
    "html": "<!doctype html><html>…</html>",
    "story_title": "Bruno und die Post",
    "story_summary": "…",
    "keywords": ["hund", "post"],
    "model": "claude-sonnet-5-5",
    "cost_eur": 0.0123,
    "email_id": 121,
    "warning": null
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="seinfeld-slots"></a>
#### Termine im Rhythmus

`GET /api/v1/seinfeld/slots`

Scope: `read` · Tarif-Funktion: `seinfeld`

Alle Versandtermine im Zeitraum mit zugeordneter Mail oder Lücke (is_gap).

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `from` | `string` | nein | Startdatum YYYY-MM-DD (Standard heute). |
| `to` | `string` | nein | Enddatum YYYY-MM-DD (Standard +14 Tage, max. 120 Tage). |

```bash
curl "https://persolz.com/api/v1/seinfeld/slots?from=2026-10-09&to=2026-10-23" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data[].date` | `string (Datum)` | Datum (YYYY-MM-DD). |
| `data[].time` | `string` | Uhrzeit (HH:MM). |
| `data[].datetime` | `string (Datum/Zeit)` | Termin als Zeitpunkt (ISO 8601). |
| `data[].email` | `object` | Zugeordnete Seinfeld-Mail oder null (Lücke). |
| `data[].email.id` | `integer` | ID der Seinfeld-Mail. |
| `data[].email.subject` | `string` | Betreff. |
| `data[].email.status` | `string` | Status der Mail. |
| `data[].is_gap` | `boolean` | Für diesen Termin gibt es noch keine Mail. |
| `data[].is_missed` | `boolean` | Termin liegt in der Vergangenheit, ohne dass etwas versendet wurde. |
| `data[].is_extra` | `boolean` | Zusätzliche Mail außerhalb des Rhythmus. |
| `data[].weekday` | `string` | Wochentag (Mo–So). |

```json
{
  "data": [
    {
      "date": "2026-10-10",
      "time": "09:00",
      "datetime": "2026-10-10T09:00:00+02:00",
      "email": {
        "id": 120,
        "subject": "Was mein Kaffeebecher über Marketing weiß",
        "status": "scheduled"
      },
      "is_gap": false,
      "is_missed": false,
      "is_extra": false,
      "weekday": "Sa"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-sequences"></a>
### Seifenopern

Story-Sequenzen (Soap-Opera-Sequenzen): Tage, Teilnehmer, Aktivierung und KI-Gliederung.

Eine Seifenoper ist eine Story-Sequenz über mehrere Tage. Kontakte nehmen Teil, sobald sie aktiv ist und sie aufgenommen wurden (per API, Liste oder Integration).

<a id="obj-sequence"></a>
#### Das Seifenopern-Objekt

Eine Seifenoper mit Auslöser, Versandzeit und Kennzahlen.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `name` | `string` | Name der Seifenoper. |
| `description` | `string` | Beschreibung. |
| `goal` | `string` | Ziel bzw. Angebot der Sequenz. |
| `status` | `string` | draft (Entwurf), active (aktiv), paused (pausiert) oder archived (archiviert). |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `trigger_type` | `string` | Auslöser: manual (nur manuell/API), list_join (beim Eintrag in eine Liste) oder api. |
| `trigger_list_id` | `integer` | Liste für den Auslöser list_join oder null. |
| `trigger_label` | `string` | Auslöser in Worten. |
| `send_time` | `string` | Versandzeit (HH:MM, Europe/Berlin). |
| `skip_weekends` | `boolean` | Samstag und Sonntag werden übersprungen. |
| `smtp_account_id` | `integer · null` | SMTP-Konto für den Versand oder null (= Standardkonto des Moduls). |
| `ai_model` | `string` | KI-Modell: sonnet (beste Qualität) oder haiku (schnell & günstig). |
| `template_key` | `string` | Design-Vorlage aus der Vorlagen-Bibliothek (z. B. seinfeld-01) oder null. |
| `include_default_attachments` | `boolean` | Standard-Anhänge werden zusätzlich mitgesendet. |
| `step_count` | `integer` | Anzahl Tage. |
| `enrolled_active` | `integer` | Aktive Teilnehmer. |
| `enrolled_completed` | `integer` | Abgeschlossene Teilnahmen. |
| `sent` | `integer` | Insgesamt versendete Mails. |
| `open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 7,
  "name": "Willkommen bei Kartenherz",
  "description": "5 Tage Story-Sequenz für neue Leads",
  "goal": "Verkauf des Kartenset-Abos",
  "status": "active",
  "status_label": "Aktiv",
  "trigger_type": "list_join",
  "trigger_list_id": 3,
  "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
  "send_time": "09:00",
  "skip_weekends": false,
  "smtp_account_id": null,
  "ai_model": "sonnet",
  "template_key": "soap-01",
  "include_default_attachments": false,
  "step_count": 5,
  "enrolled_active": 312,
  "enrolled_completed": 845,
  "sent": 4980,
  "open_rate": 0.61,
  "click_rate": 0.14,
  "created_at": "2026-09-01T10:00:00+02:00",
  "updated_at": "2026-10-01T08:30:00+02:00"
}
```

<a id="obj-step"></a>
#### Das Tages-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `sequence_id` | `integer` | ID der Seifenoper. |
| `position` | `integer` | Reihenfolge (ab 1). |
| `day` | `integer` | Versandtag (1 = Starttag). |
| `is_active` | `boolean` | Tag ist aktiv – inaktive Tage werden übersprungen. |
| `stage` | `string` | Etappe: buehne, drama, epiphanie, vorteile, dringlichkeit, einwaende, szenario, bonus, faq, letzte_chance oder custom. |
| `stage_label` | `string` | Etappe in Worten. |
| `subject` | `string` | Betreff. |
| `preheader` | `string` | Vorschautext (erscheint im Postfach neben dem Betreff). |
| `html` | `string` | HTML-Inhalt. |
| `attachment_ids` | `array` | IDs der Anhänge aus der Anhang-Cloud. |
| `include_default_attachments` | `boolean` | Standard-Anhänge werden zusätzlich mitgesendet. |
| `campaign_id` | `integer` | Interne Kampagne dieses Tages (für Statistiken). |
| `ab_test_id` | `integer · null` | ID des verknüpften A/B-Tests oder null. |
| `stats` | `object` | Kennzahlen dieses Tages. |
| `stats.sent` | `integer` | Versendet. |
| `stats.opens` | `integer` | Öffnungen gesamt (inkl. mehrfacher Öffnungen). |
| `stats.clicks` | `integer` | Klicks gesamt. |
| `stats.unsubs` | `integer` | Abmeldungen. |
| `stats.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `stats.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 31,
  "sequence_id": 7,
  "position": 1,
  "day": 1,
  "is_active": true,
  "stage": "buehne",
  "stage_label": "Bühne bereiten",
  "subject": "Wie alles mit einer Postkarte begann",
  "preheader": "Tag 1 von 5",
  "html": "<!doctype html><html>…</html>",
  "attachment_ids": [],
  "include_default_attachments": false,
  "campaign_id": 950,
  "ab_test_id": null,
  "stats": {
    "sent": 1157,
    "opens": 760,
    "clicks": 98,
    "unsubs": 4,
    "open_rate": 0.6569,
    "click_rate": 0.0847
  },
  "created_at": "2026-09-01T10:00:00+02:00",
  "updated_at": "2026-09-02T11:00:00+02:00"
}
```

<a id="obj-enrollment"></a>
#### Das Teilnahme-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `contact_id` | `integer` | ID des Kontakts. |
| `email` | `string` | E-Mail-Adresse. |
| `name` | `string` | Name des Kontakts. |
| `status` | `string` | active, paused, completed, cancelled oder unsubscribed. |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `source` | `string` | Wie der Kontakt aufgenommen wurde (api, list, manual, digistore …). |
| `start_date` | `string (Datum)` | Startdatum der Seifenoper. |
| `current_position` | `integer` | Position des zuletzt erreichten Tages. |
| `current_day` | `integer` | Aktueller Versandtag. |
| `total_steps` | `integer` | Anzahl Tage der Sequenz. |
| `progress` | `number` | Fortschritt von 0 bis 1. |
| `next_send_at` | `string (Datum/Zeit)` | Nächster geplanter Versand oder null. |
| `last_sent_at` | `string (Datum/Zeit)` | Letzter Versand an den Kontakt oder null. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 501,
  "contact_id": 42,
  "email": "anna@beispiel.de",
  "name": "Anna Schmidt",
  "status": "active",
  "status_label": "Aktiv",
  "source": "api",
  "start_date": "2026-10-08",
  "current_position": 2,
  "current_day": 2,
  "total_steps": 5,
  "progress": 0.4,
  "next_send_at": "2026-10-10T09:00:00+02:00",
  "last_sent_at": "2026-10-09T09:00:12+02:00",
  "created_at": "2026-10-08T12:00:00+02:00"
}
```

<a id="sequences-list"></a>
#### Seifenopern auflisten

`GET /api/v1/sequences`

Scope: `read` · Tarif-Funktion: `soap` · paginiert

Alle Sequenzen mit Kennzahlen (Tage, Teilnehmer, Öffnungs-/Klickrate).

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | draft \| active \| paused \| archived Werte: `draft`, `active`, `paused`, `archived`. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/sequences?status=active" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Seifenopern-Objekt](#obj-sequence)

```json
{
  "data": [
    {
      "id": 7,
      "name": "Willkommen bei Kartenherz",
      "description": "5 Tage Story-Sequenz für neue Leads",
      "goal": "Verkauf des Kartenset-Abos",
      "status": "active",
      "status_label": "Aktiv",
      "trigger_type": "list_join",
      "trigger_list_id": 3,
      "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
      "send_time": "09:00",
      "skip_weekends": false,
      "smtp_account_id": null,
      "ai_model": "sonnet",
      "template_key": "soap-01",
      "include_default_attachments": false,
      "step_count": 5,
      "enrolled_active": 312,
      "enrolled_completed": 845,
      "sent": 4980,
      "open_rate": 0.61,
      "click_rate": 0.14,
      "created_at": "2026-09-01T10:00:00+02:00",
      "updated_at": "2026-10-01T08:30:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="sequences-create"></a>
#### Seifenoper anlegen

`POST /api/v1/sequences`

Scope: `write` · Tarif-Funktion: `soap` · unterstützt `Idempotency-Key`

Legt eine Sequenz als Entwurf an (optional mit 5 vorbereiteten Tagen). Free-Tarif: 1 Sequenz.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name der Seifenoper. |
| `description` | `string` | nein | Beschreibung. |
| `goal` | `string` | nein | Ziel / Angebot. |
| `trigger_type` | `string` | nein | manual \| list_join \| api Werte: `manual`, `list_join`, `api`. |
| `trigger_list_id` | `integer` | nein | Bei list_join: Liste, deren neue Mitglieder automatisch starten. |
| `send_time` | `string` | nein | Versandzeit HH:MM. |
| `skip_weekends` | `boolean` | nein | Wochenenden überspringen. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto. |
| `ai_model` | `string` | nein | sonnet \| haiku |
| `template_key` | `string` | nein | Design-Vorlage (soap-…). |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge mitsenden. |
| `starter` | `string` | nein | Beim Anlegen: hints (5 vorbereitete Tage mit Schreibhinweisen), library (Beispieltexte der Vorlagen-Serie) oder none. Werte: `hints`, `library`, `none`. |

```bash
curl -X POST https://persolz.com/api/v1/sequences \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Willkommen bei Kartenherz",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "starter": "hints"
  }'
```

**Antwort 201** – Angelegt · [Seifenopern-Objekt](#obj-sequence)

```json
{
  "data": {
    "status": "draft",
    "status_label": "Entwurf",
    "id": 7,
    "name": "Willkommen bei Kartenherz",
    "description": "5 Tage Story-Sequenz für neue Leads",
    "goal": "Verkauf des Kartenset-Abos",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
    "send_time": "09:00",
    "skip_weekends": false,
    "smtp_account_id": null,
    "ai_model": "sonnet",
    "template_key": "soap-01",
    "include_default_attachments": false,
    "step_count": 5,
    "enrolled_active": 312,
    "enrolled_completed": 845,
    "sent": 4980,
    "open_rate": 0.61,
    "click_rate": 0.14,
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-generate"></a>
#### Gliederung mit KI planen

`POST /api/v1/sequences/generate`

Scope: `ai` · Tarif-Funktion: `soap`, `ai` · unterstützt `Idempotency-Key`

Die KI plant eine Seifenoper (3–10 Tage) aus Deinem Briefing: je Tag Etappe, Betreff, Inhalt und Cliffhanger. Mit create=true wird direkt eine Sequenz (Entwurf) mit diesen Tagen angelegt. Verbraucht KI-Guthaben.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `product` | `string` | ja | Was verkaufst Du? |
| `audience` | `string` | ja | Für wen? |
| `story` | `string` | ja | Deine Geschichte (Hintergrund, Tiefpunkt). |
| `epiphany` | `string` | nein | Der Aha-Moment. |
| `objections` | `string` | nein | Typische Einwände. |
| `url` | `string` | nein | Link zum Angebot. |
| `tone` | `string` | nein | Tonalität. |
| `days` | `integer` | nein | Anzahl Tage (3–10, Standard 5). |
| `model` | `string` | nein | sonnet \| haiku |
| `create` | `boolean` | nein | Sequenz direkt anlegen. |
| `name` | `string` | nein | Name der Sequenz bei create. |

```bash
curl -X POST https://persolz.com/api/v1/sequences/generate \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "product": "Kartenset-Abo",
    "audience": "Kartenliebhaber",
    "story": "Ich habe als Illustrator fast aufgegeben, bis …",
    "days": 5
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.title` | `string` | Titel der geplanten Story. |
| `data.days` | `array<object>` | Die geplanten Tage. |
| `data.days[].day` | `integer` | Versandtag. |
| `data.days[].stage` | `string` | Etappe. |
| `data.days[].subject` | `string` | Betreffvorschlag. |
| `data.days[].summary` | `string` | Inhalt in Kurzform. |
| `data.days[].hook` | `string` | Cliffhanger zum nächsten Tag. |
| `data.warning` | `string · null` | Hinweis (z. B. wenn die KI gekürzt hat) oder null. |
| `data.cost_eur` | `number` | Kosten dieser KI-Anfrage in Euro (wird vom KI-Guthaben abgezogen). |
| `data.sequence_id` | `integer · null` | ID der angelegten Sequenz (bei create=true) oder null. |

```json
{
  "data": {
    "title": "Von der leeren Mappe zum Abo",
    "days": [
      {
        "day": 1,
        "stage": "buehne",
        "subject": "Wie alles mit einer Postkarte begann",
        "summary": "…",
        "hook": "Morgen erzähle ich Dir, was dann schiefging."
      }
    ],
    "warning": null,
    "cost_eur": 0.021,
    "sequence_id": null
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="sequences-get"></a>
#### Seifenoper abrufen

`GET /api/v1/sequences/{id}`

Scope: `read` · Tarif-Funktion: `soap`

Sequenz mit Tagen (ohne HTML) und Statistik.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/sequences/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Seifenopern-Objekt](#obj-sequence)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.steps` | `array<Tages-Objekt>` | Tage der Sequenz (ohne HTML). Siehe [Tages-Objekt](#obj-step). |
| `data.stats` | `object` | Kennzahlen. |
| `data.stats.sent` | `integer` | Versendet. |
| `data.stats.opens` | `integer` | Öffnungen gesamt (inkl. mehrfacher Öffnungen). |
| `data.stats.clicks` | `integer` | Klicks gesamt. |
| `data.stats.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.stats.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.stats.completion_rate` | `number` | Anteil der Teilnehmer, die alle Tage erhalten haben (0 bis 1). |

```json
{
  "data": {
    "id": 7,
    "name": "Willkommen bei Kartenherz",
    "description": "5 Tage Story-Sequenz für neue Leads",
    "goal": "Verkauf des Kartenset-Abos",
    "status": "active",
    "status_label": "Aktiv",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
    "send_time": "09:00",
    "skip_weekends": false,
    "smtp_account_id": null,
    "ai_model": "sonnet",
    "template_key": "soap-01",
    "include_default_attachments": false,
    "step_count": 5,
    "enrolled_active": 312,
    "enrolled_completed": 845,
    "sent": 4980,
    "open_rate": 0.61,
    "click_rate": 0.14,
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00",
    "steps": [
      {
        "id": 31,
        "sequence_id": 7,
        "position": 1,
        "day": 1,
        "is_active": true,
        "stage": "buehne",
        "stage_label": "Bühne bereiten",
        "subject": "Wie alles mit einer Postkarte begann",
        "preheader": "Tag 1 von 5",
        "attachment_ids": [],
        "include_default_attachments": false,
        "campaign_id": 950,
        "ab_test_id": null,
        "stats": {
          "sent": 1157,
          "opens": 760,
          "clicks": 98,
          "unsubs": 4,
          "open_rate": 0.6569,
          "click_rate": 0.0847
        },
        "created_at": "2026-09-01T10:00:00+02:00",
        "updated_at": "2026-09-02T11:00:00+02:00"
      }
    ],
    "stats": {
      "sent": 4980,
      "opens": 3038,
      "clicks": 697,
      "open_rate": 0.61,
      "click_rate": 0.14,
      "completion_rate": 0.73
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-update"></a>
#### Seifenoper ändern

`PATCH /api/v1/sequences/{id}`

Scope: `write` · Tarif-Funktion: `soap`

Ändert die übergebenen Einstellungen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name der Seifenoper. |
| `description` | `string` | nein | Beschreibung. |
| `goal` | `string` | nein | Ziel / Angebot. |
| `trigger_type` | `string` | nein | manual \| list_join \| api Werte: `manual`, `list_join`, `api`. |
| `trigger_list_id` | `integer` | nein | Bei list_join: Liste, deren neue Mitglieder automatisch starten. |
| `send_time` | `string` | nein | Versandzeit HH:MM. |
| `skip_weekends` | `boolean` | nein | Wochenenden überspringen. |
| `smtp_account_id` | `integer` | nein | SMTP-Konto. |
| `ai_model` | `string` | nein | sonnet \| haiku |
| `template_key` | `string` | nein | Design-Vorlage (soap-…). |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge mitsenden. |
| `starter` | `string` | nein | Beim Anlegen: hints (5 vorbereitete Tage mit Schreibhinweisen), library (Beispieltexte der Vorlagen-Serie) oder none. Werte: `hints`, `library`, `none`. |

```bash
curl -X PATCH https://persolz.com/api/v1/sequences/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "send_time": "08:30",
    "skip_weekends": true
  }'
```

**Antwort 200** – OK · [Seifenopern-Objekt](#obj-sequence)

```json
{
  "data": {
    "id": 7,
    "name": "Willkommen bei Kartenherz",
    "description": "5 Tage Story-Sequenz für neue Leads",
    "goal": "Verkauf des Kartenset-Abos",
    "status": "active",
    "status_label": "Aktiv",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
    "send_time": "09:00",
    "skip_weekends": false,
    "smtp_account_id": null,
    "ai_model": "sonnet",
    "template_key": "soap-01",
    "include_default_attachments": false,
    "step_count": 5,
    "enrolled_active": 312,
    "enrolled_completed": 845,
    "sent": 4980,
    "open_rate": 0.61,
    "click_rate": 0.14,
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-delete"></a>
#### Seifenoper löschen

`DELETE /api/v1/sequences/{id}`

Scope: `write` · Tarif-Funktion: `soap`

Löscht die Sequenz mit allen Tagen, Statistiken und Teilnahmen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/sequences/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-steps"></a>
#### Tage auflisten

`GET /api/v1/sequences/{id}/steps`

Scope: `read` · Tarif-Funktion: `soap`

Alle Tage der Sequenz in Reihenfolge, inkl. HTML und Kennzahlen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/sequences/42/steps \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Tages-Objekt](#obj-step)

```json
{
  "data": [
    {
      "id": 31,
      "sequence_id": 7,
      "position": 1,
      "day": 1,
      "is_active": true,
      "stage": "buehne",
      "stage_label": "Bühne bereiten",
      "subject": "Wie alles mit einer Postkarte begann",
      "preheader": "Tag 1 von 5",
      "html": "<!doctype html><html>…</html>",
      "attachment_ids": [],
      "include_default_attachments": false,
      "campaign_id": 950,
      "ab_test_id": null,
      "stats": {
        "sent": 1157,
        "opens": 760,
        "clicks": 98,
        "unsubs": 4,
        "open_rate": 0.6569,
        "click_rate": 0.0847
      },
      "created_at": "2026-09-01T10:00:00+02:00",
      "updated_at": "2026-09-02T11:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-createstep"></a>
#### Tag hinzufügen

`POST /api/v1/sequences/{id}/steps`

Scope: `write` · Tarif-Funktion: `soap` · unterstützt `Idempotency-Key`

Hängt einen Tag an (oder fügt ihn hinter after_step_id ein). Max. 60 Tage.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `day` | `integer` | nein | Versandtag (1 = Starttag, max. 365). Spätere Tage werden bei Bedarf verschoben. |
| `stage` | `string` | nein | Etappe: buehne, drama, epiphanie, vorteile, dringlichkeit, einwaende, szenario, bonus, faq, letzte_chance, custom |
| `is_active` | `boolean` | nein | Aktiv (ausgesetzte Tage werden übersprungen). |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge. |
| `after_step_id` | `integer` | nein | Nur beim Anlegen: hinter diesem Tag einfügen (Standard: ans Ende). |

```bash
curl -X POST https://persolz.com/api/v1/sequences/42/steps \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Das Drama beginnt",
    "html": "<p>Gestern habe ich Dir erzählt …</p>",
    "day": 2,
    "stage": "drama"
  }'
```

**Antwort 201** – Angelegt · [Tages-Objekt](#obj-step)

```json
{
  "data": {
    "id": 31,
    "sequence_id": 7,
    "position": 1,
    "day": 1,
    "is_active": true,
    "stage": "buehne",
    "stage_label": "Bühne bereiten",
    "subject": "Wie alles mit einer Postkarte begann",
    "preheader": "Tag 1 von 5",
    "html": "<!doctype html><html>…</html>",
    "attachment_ids": [],
    "include_default_attachments": false,
    "campaign_id": 950,
    "ab_test_id": null,
    "stats": {
      "sent": 1157,
      "opens": 760,
      "clicks": 98,
      "unsubs": 4,
      "open_rate": 0.6569,
      "click_rate": 0.0847
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-09-02T11:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-updatestep"></a>
#### Tag ändern

`PATCH /api/v1/sequences/{id}/steps/{stepId}`

Scope: `write` · Tarif-Funktion: `soap`

Ändert einen Tag. Ein neuer „day“ verschiebt bei Bedarf die folgenden Tage.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |
| `stepId` | `integer` | ja | ID des Tages |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML-Inhalt. |
| `day` | `integer` | nein | Versandtag (1 = Starttag, max. 365). Spätere Tage werden bei Bedarf verschoben. |
| `stage` | `string` | nein | Etappe: buehne, drama, epiphanie, vorteile, dringlichkeit, einwaende, szenario, bonus, faq, letzte_chance, custom |
| `is_active` | `boolean` | nein | Aktiv (ausgesetzte Tage werden übersprungen). |
| `attachment_ids` | `array<integer>` | nein | Anhänge. |
| `include_default_attachments` | `boolean` | nein | Standard-Anhänge. |
| `after_step_id` | `integer` | nein | Nur beim Anlegen: hinter diesem Tag einfügen (Standard: ans Ende). |

```bash
curl -X PATCH https://persolz.com/api/v1/sequences/42/steps/31 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Neuer Betreff für Tag 1",
    "is_active": true
  }'
```

**Antwort 200** – OK · [Tages-Objekt](#obj-step)

```json
{
  "data": {
    "id": 31,
    "sequence_id": 7,
    "position": 1,
    "day": 1,
    "is_active": true,
    "stage": "buehne",
    "stage_label": "Bühne bereiten",
    "subject": "Wie alles mit einer Postkarte begann",
    "preheader": "Tag 1 von 5",
    "html": "<!doctype html><html>…</html>",
    "attachment_ids": [],
    "include_default_attachments": false,
    "campaign_id": 950,
    "ab_test_id": null,
    "stats": {
      "sent": 1157,
      "opens": 760,
      "clicks": 98,
      "unsubs": 4,
      "open_rate": 0.6569,
      "click_rate": 0.0847
    },
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-09-02T11:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-deletestep"></a>
#### Tag löschen

`DELETE /api/v1/sequences/{id}/steps/{stepId}`

Scope: `write` · Tarif-Funktion: `soap`

Löscht den Tag samt Statistik; Teilnehmer behalten ihren Fortschritt.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |
| `stepId` | `integer` | ja | ID des Tages |

```bash
curl -X DELETE https://persolz.com/api/v1/sequences/42/steps/31 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-enroll"></a>
#### Kontakte aufnehmen

`POST /api/v1/sequences/{id}/enroll`

Scope: `write` · Tarif-Funktion: `soap` · unterstützt `Idempotency-Key`

Nimmt Kontakte auf: per contact_ids, emails (unbekannte Adressen werden als Kontakte angelegt) oder alle aktiven Mitglieder einer Liste (list_id). Abgemeldete/unbestätigte Kontakte werden übersprungen. Die Mails starten, sobald die Sequenz aktiv ist.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `contact_ids` | `array<integer>` | nein | Kontakt-IDs. |
| `emails` | `array<string>` | nein | E-Mail-Adressen. |
| `list_id` | `integer` | nein | Alle aktiven Mitglieder dieser Liste. |
| `start_date` | `string` | nein | Startdatum YYYY-MM-DD, „today“ oder „tomorrow“ (Standard heute). Vergangene Daten holen verpasste Tage sofort nach. |

```bash
curl -X POST https://persolz.com/api/v1/sequences/42/enroll \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_ids": [42],
    "emails": ["ben@beispiel.de"],
    "start_date": "tomorrow"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.enrolled` | `integer` | Neu aufgenommene Kontakte. |
| `data.already` | `integer` | Bereits teilnehmende Kontakte (übersprungen). |
| `data.skipped` | `integer` | Übersprungene Kontakte (abgemeldet oder unbestätigt). |
| `data.created` | `integer` | Neu angelegte Kontakte (unbekannte E-Mail-Adressen). |
| `data.total` | `integer` | Teilnehmer insgesamt nach dem Aufnehmen. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "enrolled": 2,
    "already": 0,
    "skipped": 0,
    "created": 1,
    "total": 2,
    "message": "2 Kontakte aufgenommen. 1 neuer Kontakt wurde angelegt."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-enrollments"></a>
#### Teilnehmer auflisten

`GET /api/v1/sequences/{id}/enrollments`

Scope: `read` · Tarif-Funktion: `soap` · paginiert

Teilnahmen mit Fortschritt (aktueller Tag, nächster Versand).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | active \| paused \| completed \| cancelled \| unsubscribed Werte: `active`, `paused`, `completed`, `cancelled`, `unsubscribed`. |
| `q` | `string` | nein | Suche in Name/E-Mail. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/sequences/42/enrollments?status=active" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Teilnahme-Objekt](#obj-enrollment)

```json
{
  "data": [
    {
      "id": 501,
      "contact_id": 42,
      "email": "anna@beispiel.de",
      "name": "Anna Schmidt",
      "status": "active",
      "status_label": "Aktiv",
      "source": "api",
      "start_date": "2026-10-08",
      "current_position": 2,
      "current_day": 2,
      "total_steps": 5,
      "progress": 0.4,
      "next_send_at": "2026-10-10T09:00:00+02:00",
      "last_sent_at": "2026-10-09T09:00:12+02:00",
      "created_at": "2026-10-08T12:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-unenroll"></a>
#### Teilnehmer entfernen

`DELETE /api/v1/sequences/{id}/enrollments/{contactId}`

Scope: `write` · Tarif-Funktion: `soap`

Stoppt die Teilnahme des Kontakts (er erhält keine weiteren Mails dieser Sequenz).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |
| `contactId` | `integer` | ja | ID des Kontakts |

```bash
curl -X DELETE https://persolz.com/api/v1/sequences/42/enrollments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Entfernt

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="sequences-activate"></a>
#### Seifenoper aktivieren

`POST /api/v1/sequences/{id}/activate`

Scope: `send` · Tarif-Funktion: `soap` · unterstützt `Idempotency-Key`

Startet die Sequenz. Voraussetzung: mindestens ein aktiver Tag mit Betreff und Inhalt, ein SMTP-Konto und bei list_join eine Liste.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/sequences/42/activate \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

**Antwort 200** – OK · [Seifenopern-Objekt](#obj-sequence)

```json
{
  "data": {
    "status": "active",
    "status_label": "Aktiv",
    "id": 7,
    "name": "Willkommen bei Kartenherz",
    "description": "5 Tage Story-Sequenz für neue Leads",
    "goal": "Verkauf des Kartenset-Abos",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
    "send_time": "09:00",
    "skip_weekends": false,
    "smtp_account_id": null,
    "ai_model": "sonnet",
    "template_key": "soap-01",
    "include_default_attachments": false,
    "step_count": 5,
    "enrolled_active": 312,
    "enrolled_completed": 845,
    "sent": 4980,
    "open_rate": 0.61,
    "click_rate": 0.14,
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="sequences-pause"></a>
#### Seifenoper pausieren

`POST /api/v1/sequences/{id}/pause`

Scope: `write` · Tarif-Funktion: `soap` · unterstützt `Idempotency-Key`

Pausiert die Sequenz – es gehen keine Mails mehr raus, bis sie wieder aktiviert wird.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/sequences/42/pause \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Seifenopern-Objekt](#obj-sequence)

```json
{
  "data": {
    "status": "paused",
    "status_label": "Pausiert",
    "id": 7,
    "name": "Willkommen bei Kartenherz",
    "description": "5 Tage Story-Sequenz für neue Leads",
    "goal": "Verkauf des Kartenset-Abos",
    "trigger_type": "list_join",
    "trigger_list_id": 3,
    "trigger_label": "Beim Eintrag in „Kartenherz Funnel“",
    "send_time": "09:00",
    "skip_weekends": false,
    "smtp_account_id": null,
    "ai_model": "sonnet",
    "template_key": "soap-01",
    "include_default_attachments": false,
    "step_count": 5,
    "enrolled_active": 312,
    "enrolled_completed": 845,
    "sent": 4980,
    "open_rate": 0.61,
    "click_rate": 0.14,
    "created_at": "2026-09-01T10:00:00+02:00",
    "updated_at": "2026-10-01T08:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-templates"></a>
### Vorlagen

Vorlagen-Bibliothek und eigene E-Mail-Vorlagen.

<a id="obj-template"></a>
#### Das Vorlagen-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `source` | `string` | library (Vorlagen-Bibliothek) oder own (eigene Vorlage). |
| `id` | `integer · null` | ID der eigenen Vorlage oder null (Bibliothek). |
| `key` | `string` | Schlüssel der Bibliotheks-Vorlage (bei eigenen: die Ursprungsvorlage). |
| `ref` | `string` | Referenz für template-Felder: lib:&lt;Schlüssel&gt; oder own:&lt;ID&gt;. |
| `name` | `string` | Name. |
| `category` | `string` | Kategorie: seinfeld, soap, email, newsletter oder general. |
| `category_label` | `string` | Kategorie in Worten. |
| `description` | `string` | Beschreibung. |
| `tags` | `array<string>` | Tags (Schlagworte). |
| `subject` | `string` | Betreff. |
| `preheader` | `string` | Vorschautext (erscheint im Postfach neben dem Betreff). |
| `theme` | `string` | Farbschema der Vorlage. |
| `is_favorite` | `boolean` | Als Favorit markiert. |
| `updated_at` | `string · null` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "source": "library",
  "id": null,
  "key": "newsletter-01",
  "ref": "lib:newsletter-01",
  "name": "Wochen-Digest",
  "category": "newsletter",
  "category_label": "Newsletter",
  "description": "Drei Themen der Woche mit Bild und Button",
  "tags": ["digest"],
  "subject": "Deine Woche in 3 Minuten",
  "preheader": "Das Wichtigste auf einen Blick",
  "theme": "persolz",
  "is_favorite": false,
  "updated_at": null
}
```

<a id="templates-list"></a>
#### Vorlagen auflisten

`GET /api/v1/templates`

Scope: `read` · paginiert

Bibliothek (über 100 Vorlagen) und eigene Vorlagen, ohne HTML. Eigene Favoriten zuerst.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `category` | `string` | nein | seinfeld \| soap \| email \| newsletter \| general \| own Werte: `seinfeld`, `soap`, `email`, `newsletter`, `general`, `own`. |
| `source` | `string` | nein | library \| own Werte: `library`, `own`. |
| `q` | `string` | nein | Suche. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/templates?category=newsletter" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Vorlagen-Objekt](#obj-template)

```json
{
  "data": [
    {
      "source": "library",
      "id": null,
      "key": "newsletter-01",
      "ref": "lib:newsletter-01",
      "name": "Wochen-Digest",
      "category": "newsletter",
      "category_label": "Newsletter",
      "description": "Drei Themen der Woche mit Bild und Button",
      "tags": ["digest"],
      "subject": "Deine Woche in 3 Minuten",
      "preheader": "Das Wichtigste auf einen Blick",
      "theme": "persolz",
      "is_favorite": false,
      "updated_at": null
    },
    {
      "source": "own",
      "id": 14,
      "key": "newsletter-01",
      "ref": "own:14",
      "name": "Mein Herbst-Newsletter",
      "category": "newsletter",
      "category_label": "Newsletter",
      "description": "Basierend auf „Wochen-Digest“",
      "tags": [],
      "subject": "Herbst bei Kartenherz",
      "preheader": "Neue Karten sind da",
      "theme": "sunrise",
      "is_favorite": true,
      "updated_at": "2026-10-02T12:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 2,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="templates-create"></a>
#### Eigene Vorlage anlegen

`POST /api/v1/templates`

Scope: `write` · unterstützt `Idempotency-Key`

Speichert eine eigene Vorlage (oder kopiert eine Bibliotheks-Vorlage mit from_template). Free-Tarif: max. 3 eigene Vorlagen.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name. |
| `category` | `string` | nein | seinfeld \| soap \| email \| newsletter \| general Werte: `seinfeld`, `soap`, `email`, `newsletter`, `general`. |
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML (Pflicht, außer from_template). |
| `from_template` | `string` | nein | Kopie einer Bibliotheks-Vorlage (Schlüssel, z. B. newsletter-01). |
| `is_favorite` | `boolean` | nein | Als Favorit markieren. |

```bash
curl -X POST https://persolz.com/api/v1/templates \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mein Herbst-Newsletter",
    "from_template": "newsletter-01"
  }'
```

**Antwort 201** – Angelegt · [Vorlagen-Objekt](#obj-template)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.html` | `string` | HTML-Inhalt. |

```json
{
  "data": {
    "source": "own",
    "id": 14,
    "key": "newsletter-01",
    "ref": "own:14",
    "name": "Mein Herbst-Newsletter",
    "category": "newsletter",
    "category_label": "Newsletter",
    "description": "Basierend auf „Wochen-Digest“",
    "tags": [],
    "subject": "Herbst bei Kartenherz",
    "preheader": "Neue Karten sind da",
    "theme": "sunrise",
    "is_favorite": true,
    "updated_at": "2026-10-02T12:00:00+02:00",
    "html": "<!doctype html><html>…</html>"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="templates-get"></a>
#### Vorlage abrufen

`GET /api/v1/templates/{key}`

Scope: `read`

Eine Vorlage inkl. HTML: Bibliothek per Schlüssel (z. B. seinfeld-01, auch lib:seinfeld-01), eigene per ID (z. B. 14 oder own:14).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `key` | `string` | ja | Schlüssel der Bibliotheks-Vorlage oder ID der eigenen Vorlage |

```bash
curl https://persolz.com/api/v1/templates/seinfeld-01 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Vorlagen-Objekt](#obj-template)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.html` | `string` | HTML-Inhalt. |

```json
{
  "data": {
    "source": "library",
    "id": null,
    "key": "newsletter-01",
    "ref": "lib:newsletter-01",
    "name": "Wochen-Digest",
    "category": "newsletter",
    "category_label": "Newsletter",
    "description": "Drei Themen der Woche mit Bild und Button",
    "tags": ["digest"],
    "subject": "Deine Woche in 3 Minuten",
    "preheader": "Das Wichtigste auf einen Blick",
    "theme": "persolz",
    "is_favorite": false,
    "updated_at": null,
    "html": "<!doctype html><html>…</html>"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="templates-update"></a>
#### Eigene Vorlage ändern

`PATCH /api/v1/templates/{id}`

Scope: `write`

Ändert eine eigene Vorlage (Bibliotheks-Vorlagen sind schreibgeschützt).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `name` | `string` | ja | Name. |
| `category` | `string` | nein | seinfeld \| soap \| email \| newsletter \| general Werte: `seinfeld`, `soap`, `email`, `newsletter`, `general`. |
| `subject` | `string` | nein | Betreff. |
| `preheader` | `string` | nein | Vorschautext. |
| `html` | `string` | nein | HTML (Pflicht, außer from_template). |
| `from_template` | `string` | nein | Kopie einer Bibliotheks-Vorlage (Schlüssel, z. B. newsletter-01). |
| `is_favorite` | `boolean` | nein | Als Favorit markieren. |

```bash
curl -X PATCH https://persolz.com/api/v1/templates/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Neuer Betreff",
    "is_favorite": true
  }'
```

**Antwort 200** – OK · [Vorlagen-Objekt](#obj-template)

```json
{
  "data": {
    "source": "own",
    "id": 14,
    "key": "newsletter-01",
    "ref": "own:14",
    "name": "Mein Herbst-Newsletter",
    "category": "newsletter",
    "category_label": "Newsletter",
    "description": "Basierend auf „Wochen-Digest“",
    "tags": [],
    "subject": "Herbst bei Kartenherz",
    "preheader": "Neue Karten sind da",
    "theme": "sunrise",
    "is_favorite": true,
    "updated_at": "2026-10-02T12:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="templates-delete"></a>
#### Eigene Vorlage löschen

`DELETE /api/v1/templates/{id}`

Scope: `write`

Löscht eine eigene Vorlage.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/templates/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-attachments"></a>
### Anhänge

Dateien in der Anhang-Cloud hochladen, auflisten und löschen.

<a id="obj-attachment"></a>
#### Das Anhang-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `name` | `string` | Dateiname. |
| `size` | `integer` | Größe in Bytes. |
| `size_human` | `string` | Größe, lesbar formatiert. |
| `mime` | `string` | MIME-Typ. |
| `ext` | `string` | Dateiendung. |
| `group` | `string` | Dateigruppe: document, image, audio, archive … |
| `is_default` | `boolean` | Standard-Anhang (wird bei „Standard-Anhänge mitsenden“ angehängt). |
| `description` | `string` | Beschreibung. |
| `image_url` | `string` | Nur bei Bildern (JPG, PNG, GIF, WebP): öffentliche, nicht erratbare Adresse für &lt;img src&gt; in E-Mails (/i/&lt;Token&gt;/&lt;Name&gt;), sonst null. |
| `width` | `integer` | Bildbreite in Pixeln (nur bei Bildern, sonst null). |
| `height` | `integer` | Bildhöhe in Pixeln (nur bei Bildern, sonst null). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 12,
  "name": "Team-Foto.jpg",
  "size": 248112,
  "size_human": "242,3 KB",
  "mime": "image/jpeg",
  "ext": "jpg",
  "group": "image",
  "is_default": false,
  "description": "Unser Team im Sommer",
  "image_url": "https://persolz.com/i/3f9a2c7d1e5b4a60b8c9d0e1f2a3b4c5/team-foto.jpg",
  "width": 1200,
  "height": 800,
  "created_at": "2026-10-02T11:30:00+02:00"
}
```

<a id="attachments-list"></a>
#### Anhänge auflisten

`GET /api/v1/attachments`

Scope: `read` · Tarif-Funktion: `attachments` · paginiert

Dateien der Anhang-Cloud mit Speicherverbrauch. Bilder (JPG, PNG, GIF, WebP) haben zusätzlich `image_url` (öffentliche, nicht erratbare Adresse für das `src`-Attribut von Bildern in E-Mails) sowie `width`/`height` in Pixeln – bei allen anderen Dateien (auch SVG) sind diese Felder `null`. Die Liste zeigt nur bereits vorhandene Bild-Links (ein widerrufener Link wird hier nicht neu erzeugt); `GET /attachments/{id}` erzeugt den Link bei Bedarf.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `q` | `string` | nein | Suche im Namen/in der Beschreibung. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl https://persolz.com/api/v1/attachments \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Anhang-Objekt](#obj-attachment)

```json
{
  "data": [
    {
      "id": 9,
      "name": "Preisliste-2026.pdf",
      "size": 182340,
      "size_human": "178,1 KB",
      "mime": "application/pdf",
      "ext": "pdf",
      "group": "document",
      "is_default": false,
      "description": "Aktuelle Preisliste",
      "image_url": null,
      "width": null,
      "height": null,
      "created_at": "2026-10-01T09:00:00+02:00"
    },
    {
      "id": 12,
      "name": "Team-Foto.jpg",
      "size": 248112,
      "size_human": "242,3 KB",
      "mime": "image/jpeg",
      "ext": "jpg",
      "group": "image",
      "is_default": false,
      "description": "Unser Team im Sommer",
      "image_url": "https://persolz.com/i/3f9a2c7d1e5b4a60b8c9d0e1f2a3b4c5/team-foto.jpg",
      "width": 1200,
      "height": 800,
      "created_at": "2026-10-02T11:30:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 2,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="attachments-upload"></a>
#### Anhang hochladen

`POST /api/v1/attachments`

Scope: `write` · Tarif-Funktion: `attachments` · unterstützt `Idempotency-Key`

Upload als multipart/form-data (Feld „file“) oder JSON {name, content_base64}. Erlaubt sind u. a. PDF, Bilder, Office-Dateien, TXT/CSV, ICS, ZIP, MP3 (max. 10 MB je Datei). Ausführbare Dateien werden abgelehnt. Bei Bildern (JPG, PNG, GIF, WebP) enthält die Antwort `image_url` – damit baust Du das Bild direkt ins HTML Deiner E-Mails ein (z. B. bei `POST /emails/send`).

**Request-Body** (`multipart/form-data`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `file` | `file` | nein | Die Datei (multipart). |
| `name` | `string` | nein | JSON: Dateiname mit Endung. |
| `content_base64` | `string` | nein | JSON: Dateiinhalt Base64-kodiert. |
| `description` | `string` | nein | Beschreibung. |
| `is_default` | `boolean` | nein | Standard-Anhang (wird bei „Standard-Anhänge mitsenden“ angehängt). |

```bash
curl -X POST https://persolz.com/api/v1/attachments \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -F "file=@Preisliste-2026.pdf" \
  -F "description=Aktuelle Preisliste"
```

**Antwort 201** – Hochgeladen · [Anhang-Objekt](#obj-attachment)

```json
{
  "data": {
    "id": 9,
    "name": "Preisliste-2026.pdf",
    "size": 182340,
    "size_human": "178,1 KB",
    "mime": "application/pdf",
    "ext": "pdf",
    "group": "document",
    "is_default": false,
    "description": "Aktuelle Preisliste",
    "image_url": null,
    "width": null,
    "height": null,
    "created_at": "2026-10-01T09:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 413 `payload_too_large`, 422 `validation_error`, 429 `rate_limited`.

<a id="attachments-get"></a>
#### Anhang abrufen

`GET /api/v1/attachments/{id}`

Scope: `read` · Tarif-Funktion: `attachments`

Metadaten eines Anhangs (ohne Dateiinhalt). Bei Bildern mit `image_url`, `width` und `height` – fehlt noch ein öffentlicher Bild-Link (z. B. nach dem Widerrufen), wird hier ein neuer, anderer Link erzeugt.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/attachments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Anhang-Objekt](#obj-attachment)

```json
{
  "data": {
    "id": 12,
    "name": "Team-Foto.jpg",
    "size": 248112,
    "size_human": "242,3 KB",
    "mime": "image/jpeg",
    "ext": "jpg",
    "group": "image",
    "is_default": false,
    "description": "Unser Team im Sommer",
    "image_url": "https://persolz.com/i/3f9a2c7d1e5b4a60b8c9d0e1f2a3b4c5/team-foto.jpg",
    "width": 1200,
    "height": 800,
    "created_at": "2026-10-02T11:30:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="attachments-delete"></a>
#### Anhang löschen

`DELETE /api/v1/attachments/{id}`

Scope: `write` · Tarif-Funktion: `attachments`

Löscht die Datei. In bereits geplanten Mails wird sie übersprungen; ist sie als Bild eingebaut, liefert ihre `image_url` danach 404.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/attachments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-smtp"></a>
### SMTP-Server

Verbundene SMTP-Konten (ohne Passwörter) und Verbindungstest.

<a id="obj-smtp"></a>
#### Das SMTP-Konto-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `label` | `string` | Bezeichnung des Kontos. |
| `from_name` | `string` | Absendername. |
| `from_email` | `string` | Absenderadresse. |
| `reply_to` | `string · null` | Antwort-Adresse oder null. |
| `host` | `string` | SMTP-Server. |
| `port` | `integer` | Port. |
| `encryption` | `string` | Verschlüsselung: ssl, tls oder none. |
| `username` | `string` | Benutzername für die Anmeldung. |
| `has_password` | `boolean` | Ein Passwort ist hinterlegt (es wird nie ausgegeben). |
| `auth` | `boolean` | Anmeldung am Server ist aktiv. |
| `hourly_limit` | `integer` | Max. Mails pro Stunde (0 = kein Limit). |
| `daily_limit` | `integer` | Max. Mails pro Tag (0 = kein Limit). |
| `is_default` | `boolean` | Standardkonto für den Versand. |
| `status` | `string` | ok, error oder untested. |
| `last_error` | `string · null` | Letzte Fehlermeldung oder null. |
| `last_tested_at` | `string (Datum/Zeit)` | Letzter Verbindungstest oder null. |
| `sent_today` | `integer` | Heute über dieses Konto versendet. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 1,
  "label": "Strato Postfach",
  "from_name": "Kartenherz",
  "from_email": "hallo@kartenherz.com",
  "reply_to": null,
  "host": "smtp.strato.de",
  "port": 465,
  "encryption": "ssl",
  "username": "hallo@kartenherz.com",
  "has_password": true,
  "auth": true,
  "hourly_limit": 0,
  "daily_limit": 0,
  "is_default": true,
  "status": "ok",
  "last_error": null,
  "last_tested_at": "2026-10-01T09:00:00+02:00",
  "sent_today": 140,
  "created_at": "2026-09-01T10:00:00+02:00"
}
```

<a id="smtp-list"></a>
#### SMTP-Konten auflisten

`GET /api/v1/smtp-accounts`

Scope: `read`

Verbundene SMTP-Server – Passwörter werden nie ausgegeben (nur has_password).

```bash
curl https://persolz.com/api/v1/smtp-accounts \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [SMTP-Konto-Objekt](#obj-smtp)

```json
{
  "data": [
    {
      "id": 1,
      "label": "Strato Postfach",
      "from_name": "Kartenherz",
      "from_email": "hallo@kartenherz.com",
      "reply_to": null,
      "host": "smtp.strato.de",
      "port": 465,
      "encryption": "ssl",
      "username": "hallo@kartenherz.com",
      "has_password": true,
      "auth": true,
      "hourly_limit": 0,
      "daily_limit": 0,
      "is_default": true,
      "status": "ok",
      "last_error": null,
      "last_tested_at": "2026-10-01T09:00:00+02:00",
      "sent_today": 140,
      "created_at": "2026-09-01T10:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="smtp-test"></a>
#### Verbindung testen

`POST /api/v1/smtp-accounts/{id}/test`

Scope: `write` · unterstützt `Idempotency-Key`

Testet die Verbindung (Anmeldung beim Server). Mit send_to wird zusätzlich eine Testmail verschickt. Bei Fehlern gibt es einen deutschen Lösungshinweis.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `send_to` | `string` | nein | Testmail an diese Adresse (optional). |

```bash
curl -X POST https://persolz.com/api/v1/smtp-accounts/42/test \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "send_to": "du@beispiel.de"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.ok` | `boolean` | Verbindung (und ggf. Testmail) erfolgreich. |
| `data.error` | `string · null` | Fehlermeldung oder null. |
| `data.hint` | `string · null` | Deutscher Lösungshinweis bei Fehlern oder null. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |
| `data.tested_at` | `string (Datum/Zeit)` | Zeitpunkt des Tests. |
| `data.sent_to` | `string` | Empfänger der Testmail oder null. |

```json
{
  "data": {
    "ok": true,
    "error": null,
    "hint": null,
    "message": "Verbindung erfolgreich.",
    "tested_at": "2026-10-09T14:05:00+02:00",
    "sent_to": "du@beispiel.de"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="ref-campaigns"></a>
### Kampagnen & Analytics

Alle Versände mit Status und Kennzahlen, Gesamtauswertung.

<a id="campaigns-list"></a>
#### Kampagnen auflisten

`GET /api/v1/campaigns`

Scope: `read` · paginiert

Alle Versände (Newsletter, E-Mails, Seinfeld-Mails, Seifenopern-Tage) mit Status und Kennzahlen.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `type` | `string` | nein | email \| newsletter \| seinfeld \| soap Werte: `email`, `newsletter`, `seinfeld`, `soap`. |
| `status` | `string` | nein | draft \| scheduled \| queued \| sending \| sent \| paused \| cancelled \| failed \| active |
| `q` | `string` | nein | Suche in Name/Betreff. |
| `created_after` | `string` | nein | Angelegt nach. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/campaigns?type=newsletter&status=sent" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": [
    {
      "id": 812,
      "type": "newsletter",
      "type_label": "Newsletter",
      "name": "Herbst-Aktion",
      "subject": "Anna, das hier ist nur für Dich",
      "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
      "status": "sent",
      "status_label": "Gesendet",
      "audience": {
        "all": false,
        "lists": [3],
        "contacts": [],
        "emails": []
      },
      "audience_label": "1 Liste",
      "smtp_account_id": 1,
      "attachment_ids": [],
      "include_default_attachments": false,
      "track_opens": true,
      "track_clicks": true,
      "ab_test_id": null,
      "scheduled_at": null,
      "started_at": "2026-10-09T09:00:00+02:00",
      "finished_at": "2026-10-09T09:04:31+02:00",
      "stats": {
        "recipients": 1250,
        "sent": 1248,
        "failed": 2,
        "unique_opens": 702,
        "unique_clicks": 133,
        "unsubs": 3,
        "open_rate": 0.5625,
        "click_rate": 0.1066
      },
      "created_at": "2026-10-08T17:20:00+02:00",
      "updated_at": "2026-10-09T09:04:31+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="campaigns-get"></a>
#### Kampagne abrufen

`GET /api/v1/campaigns/{id}`

Scope: `read`

Eine Kampagne mit Kennzahlen (HTML mit ?include=html).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `include` | `string` | nein | html = HTML-Inhalt mitliefern |

```bash
curl https://persolz.com/api/v1/campaigns/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Kampagnen-Objekt](#obj-campaign)

```json
{
  "data": {
    "id": 812,
    "type": "newsletter",
    "type_label": "Newsletter",
    "name": "Herbst-Aktion",
    "subject": "Anna, das hier ist nur für Dich",
    "preheader": "Nur bis Sonntag: Dein Herbst-Bonus",
    "status": "sent",
    "status_label": "Gesendet",
    "audience": {
      "all": false,
      "lists": [3],
      "contacts": [],
      "emails": []
    },
    "audience_label": "1 Liste",
    "smtp_account_id": 1,
    "attachment_ids": [],
    "include_default_attachments": false,
    "track_opens": true,
    "track_clicks": true,
    "ab_test_id": null,
    "scheduled_at": null,
    "started_at": "2026-10-09T09:00:00+02:00",
    "finished_at": "2026-10-09T09:04:31+02:00",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "unique_opens": 702,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066
    },
    "created_at": "2026-10-08T17:20:00+02:00",
    "updated_at": "2026-10-09T09:04:31+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="campaigns-stats"></a>
#### Kampagnen-Statistik

`GET /api/v1/campaigns/{id}/stats`

Scope: `read`

Detaillierte Zahlen und Quoten einer Kampagne, Top-Links und Geräteverteilung.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/campaigns/42/stats \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.id` | `integer` | ID der Kampagne. |
| `data.type` | `string` | Versandart: email, newsletter, seinfeld oder soap. |
| `data.status` | `string` | Status der Kampagne. |
| `data.stats` | `object` | Kennzahlen. |
| `data.stats.recipients` | `integer` | Empfänger gesamt. |
| `data.stats.sent` | `integer` | Versendet. |
| `data.stats.failed` | `integer` | Fehlgeschlagen. |
| `data.stats.pending` | `integer` | Noch nicht versendet. |
| `data.stats.held` | `integer` | Wartet auf den Gewinner eines A/B-Tests. |
| `data.stats.skipped` | `integer` | Übersprungen (z. B. inzwischen abgemeldet). |
| `data.stats.cancelled` | `integer` | Abgebrochen. |
| `data.stats.opens` | `integer` | Öffnungen gesamt (inkl. mehrfacher Öffnungen). |
| `data.stats.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `data.stats.clicks` | `integer` | Klicks gesamt. |
| `data.stats.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `data.stats.unsubs` | `integer` | Abmeldungen. |
| `data.stats.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.stats.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.stats.ctor` | `number` | Click-to-Open-Rate von 0 bis 1 (eindeutige Klicks / eindeutige Öffnungen). |
| `data.stats.unsub_rate` | `number` | Abmeldequote von 0 bis 1. |
| `data.stats.bounce_rate` | `number` | Bounce-Quote von 0 bis 1. |
| `data.stats.failed_rate` | `number` | Anteil fehlgeschlagener Zustellungen von 0 bis 1. |
| `data.stats.progress` | `number` | Fortschritt des Versands von 0 bis 1. |
| `data.links` | `array<object>` | Meistgeklickte Links. |
| `data.links[].url` | `string` | Ziel-URL des Links. |
| `data.links[].label` | `string` | Linktext. |
| `data.links[].clicks` | `integer` | Klicks gesamt. |
| `data.links[].unique_clicks` | `integer` | Empfänger, die geklickt haben. |
| `data.devices` | `object` | Öffnungen nach Gerät. |
| `data.devices.desktop` | `integer` | Öffnungen am Desktop. |
| `data.devices.mobile` | `integer` | Öffnungen auf dem Smartphone. |
| `data.devices.tablet` | `integer` | Öffnungen auf dem Tablet. |

```json
{
  "data": {
    "id": 812,
    "type": "newsletter",
    "status": "sent",
    "stats": {
      "recipients": 1250,
      "sent": 1248,
      "failed": 2,
      "pending": 0,
      "held": 0,
      "skipped": 0,
      "cancelled": 0,
      "opens": 980,
      "unique_opens": 702,
      "clicks": 190,
      "unique_clicks": 133,
      "unsubs": 3,
      "open_rate": 0.5625,
      "click_rate": 0.1066,
      "ctor": 0.1895,
      "unsub_rate": 0.0024,
      "bounce_rate": 0.0016,
      "failed_rate": 0.0016,
      "progress": 1.0
    },
    "links": [
      {
        "url": "https://kartenherz.com/angebot",
        "label": "Jetzt ansehen",
        "clicks": 140,
        "unique_clicks": 118
      }
    ],
    "devices": {
      "desktop": 312,
      "mobile": 365,
      "tablet": 25
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="analytics-overview"></a>
#### Analytics-Übersicht

`GET /api/v1/analytics/overview`

Scope: `read`

Gesamtauswertung für einen Zeitraum: KPIs mit Vorperioden-Vergleich, Verlauf, Module, Geräte, Top-Links und Listenwachstum. Bots werden ausgeschlossen (bots=true schließt sie ein).

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `from` | `string` | nein | Start YYYY-MM-DD (Standard: vor 29 Tagen). |
| `to` | `string` | nein | Ende YYYY-MM-DD (Standard: heute). |
| `type` | `string` | nein | all \| seinfeld \| soap \| newsletter \| email Werte: `all`, `seinfeld`, `soap`, `newsletter`, `email`. |
| `bots` | `boolean` | nein | Bot-Öffnungen/-Klicks einbeziehen. |

```bash
curl "https://persolz.com/api/v1/analytics/overview?from=2026-09-10&to=2026-10-09" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.period` | `object` | Ausgewerteter Zeitraum. |
| `data.period.from` | `string (Datum)` | Beginn des Zeitraums. |
| `data.period.to` | `string (Datum)` | Ende des Zeitraums. |
| `data.period.days` | `integer` | Anzahl Tage. |
| `data.period.type` | `string` | Gefilterter Versandtyp. |
| `data.period.label` | `string` | Zeitraum in Worten. |
| `data.kpis` | `object` | Kennzahlen des Zeitraums. |
| `data.kpis.sent` | `integer` | Versendete E-Mails. |
| `data.kpis.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `data.kpis.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `data.kpis.unsubs` | `integer` | Abmeldungen. |
| `data.kpis.open_rate` | `number` | Öffnungsrate von 0 bis 1 (eindeutige Öffnungen / versendet). |
| `data.kpis.click_rate` | `number` | Klickrate von 0 bis 1 (eindeutige Klicks / versendet). |
| `data.timeline` | `object` | Verlauf je Tag (für Diagramme). |
| `data.timeline.labels` | `array<string>` | Beschriftungen der Zeitachse. |
| `data.timeline.series` | `array` | Datenreihen. |
| `data.modules` | `array` | Kennzahlen je Modul (Seinfeld, Seifenopern, Newsletter, E-Mails). |
| `data.devices` | `array` | Öffnungen nach Gerät. |
| `data.links` | `array` | Meistgeklickte Links. |
| `data.growth` | `array` | Listenwachstum im Zeitraum. |

```json
{
  "data": {
    "period": {
      "from": "2026-09-10",
      "to": "2026-10-09",
      "days": 30,
      "type": "all",
      "label": "letzte 30 Tage (10.09.–09.10.2026)"
    },
    "kpis": {
      "sent": 8420,
      "unique_opens": 4630,
      "unique_clicks": 912,
      "unsubs": 21,
      "open_rate": 0.55,
      "click_rate": 0.108
    },
    "timeline": {
      "labels": ["10.09.", "11.09."],
      "series": []
    },
    "modules": [],
    "devices": [],
    "links": [],
    "growth": []
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-ab-tests"></a>
### A/B-Tests

A/B-Tests anlegen, Ergebnisse lesen und den Gewinner festlegen.

<a id="obj-abtest"></a>
#### Das A/B-Test-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `name` | `string` | Name. |
| `target_type` | `string` | Getestete Mail: newsletter, email, seinfeld oder soap. |
| `target_id` | `integer` | ID der getesteten Mail. |
| `test_type` | `string` | Was getestet wird: subject, content, design, sender oder combo. |
| `mode` | `string` | split (fest aufteilen) oder winner (Testgruppe, danach Gewinner an alle). |
| `split_a` | `integer` | Anteil von Variante A in Prozent. |
| `sample_pct` | `integer` | Größe der Testgruppe in Prozent (bei winner). |
| `wait_hours` | `integer` | Wartezeit bis zur Entscheidung in Stunden. |
| `winner_metric` | `string` | Entscheidungsmetrik: clicks, opens oder ctr. |
| `status` | `string` | draft (bereit), running (läuft), completed (entschieden) oder stopped. |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `winner` | `string · null` | Gewinner-Variante (A oder B) oder null. |
| `auto_apply_winner` | `boolean` | Der Gewinner wird automatisch an die übrigen Empfänger versendet. |
| `variants` | `array<object>` | Die Varianten. |
| `variants[].variant` | `string` | Variante (A oder B). |
| `variants[].subject` | `string` | Betreff der Variante. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 21,
  "name": "Betreff-Test: Herbst-Aktion",
  "target_type": "newsletter",
  "target_id": 812,
  "test_type": "subject",
  "mode": "winner",
  "split_a": 50,
  "sample_pct": 20,
  "wait_hours": 4,
  "winner_metric": "clicks",
  "status": "running",
  "status_label": "Läuft",
  "winner": null,
  "auto_apply_winner": true,
  "variants": [
    {
      "variant": "A",
      "subject": "Anna, das hier ist nur für Dich"
    },
    {
      "variant": "B",
      "subject": "Dein Herbst-Bonus wartet"
    }
  ],
  "created_at": "2026-10-08T17:30:00+02:00",
  "updated_at": "2026-10-09T09:00:00+02:00"
}
```

<a id="abtests-list"></a>
#### A/B-Tests auflisten

`GET /api/v1/ab-tests`

Scope: `read` · Tarif-Funktion: `ab_tests` · paginiert

Alle A/B-Tests, aktive zuerst.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | all \| active \| draft \| running \| completed \| stopped |
| `type` | `string` | nein | newsletter \| email \| seinfeld \| soap |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/ab-tests?status=active" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [A/B-Test-Objekt](#obj-abtest)

```json
{
  "data": [
    {
      "id": 21,
      "name": "Betreff-Test: Herbst-Aktion",
      "target_type": "newsletter",
      "target_id": 812,
      "test_type": "subject",
      "mode": "winner",
      "split_a": 50,
      "sample_pct": 20,
      "wait_hours": 4,
      "winner_metric": "clicks",
      "status": "running",
      "status_label": "Läuft",
      "winner": null,
      "auto_apply_winner": true,
      "variants": [
        {
          "variant": "A",
          "subject": "Anna, das hier ist nur für Dich"
        },
        {
          "variant": "B",
          "subject": "Dein Herbst-Bonus wartet"
        }
      ],
      "created_at": "2026-10-08T17:30:00+02:00",
      "updated_at": "2026-10-09T09:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="abtests-create"></a>
#### A/B-Test anlegen

`POST /api/v1/ab-tests`

Scope: `write` · Tarif-Funktion: `ab_tests` · unterstützt `Idempotency-Key`

Legt einen Test für eine noch nicht versendete Mail an. Variante A = aktueller Inhalt, Variante B = Deine Änderungen (b_subject, b_preheader, b_html, b_from_name). Der Test startet mit dem Versand der Mail.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `target_type` | `string` | ja | newsletter \| email \| seinfeld \| soap (Pflicht, außer bei campaign_id) |
| `target_id` | `integer` | ja | ID der Kampagne (newsletter/email), Seinfeld-Mail bzw. Seifenopern-Tag (Pflicht, außer bei campaign_id). |
| `campaign_id` | `integer` | nein | Kurzform für Newsletter und E-Mails: statt target_type + target_id nur die Kampagnen-ID. |
| `test_type` | `string` | ja | subject \| content \| design \| sender \| combo |
| `name` | `string` | nein | Name. |
| `mode` | `string` | nein | split (Aufteilen) \| winner (Testgruppe, dann Gewinner an alle) |
| `split_a` | `integer` | nein | Anteil Variante A in % (10–90). |
| `sample_pct` | `integer` | nein | Bei winner: Testgruppe in % (5–50). |
| `wait_hours` | `integer` | nein | Bei winner: Wartezeit bis zur Entscheidung (1–72 Std.). |
| `winner_metric` | `string` | nein | clicks \| opens \| ctr |
| `auto_apply_winner` | `boolean` | nein | Gewinner automatisch übernehmen. |
| `b_subject` | `string` | nein | Betreff der Variante B. |
| `b_preheader` | `string` | nein | Preheader B. |
| `b_html` | `string` | nein | Inhalt B. |
| `b_from_name` | `string` | nein | Absendername B. |

```bash
curl -X POST https://persolz.com/api/v1/ab-tests \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "target_type": "newsletter",
    "target_id": 812,
    "test_type": "subject",
    "mode": "winner",
    "b_subject": "Dein Herbst-Bonus wartet"
  }'
```

**Antwort 201** – Angelegt · [A/B-Test-Objekt](#obj-abtest)

```json
{
  "data": {
    "status": "draft",
    "status_label": "Bereit",
    "id": 21,
    "name": "Betreff-Test: Herbst-Aktion",
    "target_type": "newsletter",
    "target_id": 812,
    "test_type": "subject",
    "mode": "winner",
    "split_a": 50,
    "sample_pct": 20,
    "wait_hours": 4,
    "winner_metric": "clicks",
    "winner": null,
    "auto_apply_winner": true,
    "variants": [
      {
        "variant": "A",
        "subject": "Anna, das hier ist nur für Dich"
      },
      {
        "variant": "B",
        "subject": "Dein Herbst-Bonus wartet"
      }
    ],
    "created_at": "2026-10-08T17:30:00+02:00",
    "updated_at": "2026-10-09T09:00:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="abtests-get"></a>
#### A/B-Test abrufen

`GET /api/v1/ab-tests/{id}`

Scope: `read` · Tarif-Funktion: `ab_tests`

Test mit Varianten und Ergebnissen (Versand, Öffnungen, Klicks, Signifikanz).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/ab-tests/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [A/B-Test-Objekt](#obj-abtest)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.results` | `object` | Ergebnisse des Tests. |
| `data.results.metric` | `string` | Entscheidungsmetrik. |
| `data.results.variants` | `object` | Zahlen je Variante. |
| `data.results.variants.A` | `object` | Zahlen dieser Variante. |
| `data.results.variants.A.sent` | `integer` | Versendet. |
| `data.results.variants.A.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `data.results.variants.A.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `data.results.variants.B` | `object` | Zahlen dieser Variante. |
| `data.results.variants.B.sent` | `integer` | Versendet. |
| `data.results.variants.B.unique_opens` | `integer` | Empfänger mit mindestens einer Öffnung. |
| `data.results.variants.B.unique_clicks` | `integer` | Empfänger mit mindestens einem Klick. |
| `data.results.confidence` | `number` | Statistische Sicherheit des Vorsprungs von 0 bis 1. |
| `data.results.leader` | `string` | Aktuell führende Variante. |

```json
{
  "data": {
    "id": 21,
    "name": "Betreff-Test: Herbst-Aktion",
    "target_type": "newsletter",
    "target_id": 812,
    "test_type": "subject",
    "mode": "winner",
    "split_a": 50,
    "sample_pct": 20,
    "wait_hours": 4,
    "winner_metric": "clicks",
    "status": "running",
    "status_label": "Läuft",
    "winner": null,
    "auto_apply_winner": true,
    "variants": [
      {
        "variant": "A",
        "subject": "Anna, das hier ist nur für Dich"
      },
      {
        "variant": "B",
        "subject": "Dein Herbst-Bonus wartet"
      }
    ],
    "created_at": "2026-10-08T17:30:00+02:00",
    "updated_at": "2026-10-09T09:00:00+02:00",
    "results": {
      "metric": "clicks",
      "variants": {
        "A": {
          "sent": 125,
          "unique_opens": 70,
          "unique_clicks": 11
        },
        "B": {
          "sent": 125,
          "unique_opens": 78,
          "unique_clicks": 19
        }
      },
      "confidence": 0.91,
      "leader": "B"
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="abtests-winner"></a>
#### Gewinner festlegen

`POST /api/v1/ab-tests/{id}/winner`

Scope: `write` · Tarif-Funktion: `ab_tests` · unterstützt `Idempotency-Key`

Legt den Gewinner fest (variant A/B) oder lässt ihn aus den Daten ermitteln (variant=auto). Wartende Empfänger erhalten danach den Gewinner. Möglich, sobald der Test läuft (also nach dem Versand der Mail).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `variant` | `string` | nein | A \| B \| auto Werte: `A`, `B`, `auto`. |

```bash
curl -X POST https://persolz.com/api/v1/ab-tests/42/winner \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "variant": "B"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.winner` | `string` | Festgelegte Gewinner-Variante. |
| `data.released` | `integer` | Wartende Empfänger, die jetzt den Gewinner bekommen. |
| `data.copied` | `boolean` | Der Inhalt des Gewinners wurde in die Mail übernommen. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |

```json
{
  "data": {
    "winner": "B",
    "released": 1000,
    "copied": false,
    "message": "Variante B ist der Gewinner. 1.000 wartende Empfänger bekommen jetzt Variante B."
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="ref-appointments"></a>
### Termine

Terminbuch: Termine anlegen, ändern und abfragen.

<a id="obj-appointment"></a>
#### Das Termin-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `title` | `string` | Titel des Termins. |
| `description` | `string` | Beschreibung. |
| `location` | `string` | Ort oder Link (z. B. Zoom). |
| `start_at` | `string (Datum/Zeit)` | Beginn (ISO 8601). |
| `end_at` | `string (Datum/Zeit)` | Ende (ISO 8601). |
| `all_day` | `boolean` | Ganztägiger Termin. |
| `contact_id` | `integer` | Verknüpfter Kontakt oder null. |
| `contact` | `object` | Kurzinfo zum verknüpften Kontakt oder null. |
| `contact.id` | `integer` | ID des Kontakts. |
| `contact.name` | `string` | Name des Kontakts. |
| `contact.email` | `string` | E-Mail-Adresse. |
| `contact.phone` | `string` | Telefonnummer für den Termin. |
| `attendees` | `array<string>` | E-Mail-Adressen der Teilnehmer. |
| `phone` | `string` | Telefonnummer für den Termin. |
| `color` | `string` | Farbe im Kalender: violet, coral, mint, gold, blue, pink, green oder lilac. |
| `status` | `string` | planned (geplant), done (erledigt), cancelled (abgesagt) oder no_show (nicht erschienen). |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `reminder_minutes` | `integer` | Erinnerung so viele Minuten vor Beginn (-1 = keine). |
| `remind_attendees` | `boolean` | Teilnehmer werden ebenfalls erinnert. |
| `source` | `string` | Herkunft: app, api, google … |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 55,
  "title": "Erstgespräch Anna Schmidt",
  "description": "Kennenlernen per Video",
  "location": "Zoom",
  "start_at": "2026-10-14T10:00:00+02:00",
  "end_at": "2026-10-14T10:30:00+02:00",
  "all_day": false,
  "contact_id": 42,
  "contact": {
    "id": 42,
    "name": "Anna Schmidt",
    "email": "anna@beispiel.de",
    "phone": "+49 30 1234567"
  },
  "attendees": ["anna@beispiel.de"],
  "phone": "",
  "color": "violet",
  "status": "planned",
  "status_label": "Geplant",
  "reminder_minutes": 60,
  "remind_attendees": true,
  "source": "api",
  "created_at": "2026-10-09T14:05:00+02:00",
  "updated_at": "2026-10-09T14:05:00+02:00"
}
```

<a id="appointments-list"></a>
#### Termine abfragen

`GET /api/v1/appointments`

Scope: `read` · Tarif-Funktion: `calendar` · paginiert

Termine im Zeitraum (Standard: heute bis +30 Tage), sortiert nach Beginn.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `from` | `string` | nein | Ab (YYYY-MM-DD oder ISO 8601). |
| `to` | `string` | nein | Bis (max. 1 Jahr Spanne). |
| `status` | `string` | nein | Nur dieser Status. |
| `contact_id` | `integer` | nein | Nur Termine dieses Kontakts. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/appointments?from=2026-10-01&to=2026-10-31" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Termin-Objekt](#obj-appointment)

```json
{
  "data": [
    {
      "id": 55,
      "title": "Erstgespräch Anna Schmidt",
      "description": "Kennenlernen per Video",
      "location": "Zoom",
      "start_at": "2026-10-14T10:00:00+02:00",
      "end_at": "2026-10-14T10:30:00+02:00",
      "all_day": false,
      "contact_id": 42,
      "contact": {
        "id": 42,
        "name": "Anna Schmidt",
        "email": "anna@beispiel.de",
        "phone": "+49 30 1234567"
      },
      "attendees": ["anna@beispiel.de"],
      "phone": "",
      "color": "violet",
      "status": "planned",
      "status_label": "Geplant",
      "reminder_minutes": 60,
      "remind_attendees": true,
      "source": "api",
      "created_at": "2026-10-09T14:05:00+02:00",
      "updated_at": "2026-10-09T14:05:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="appointments-create"></a>
#### Termin anlegen

`POST /api/v1/appointments`

Scope: `write` · Tarif-Funktion: `calendar` · unterstützt `Idempotency-Key`

Legt einen Termin an (Webhook appointment.created; Google-Kalender-Sync, falls verbunden).

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `title` | `string` | ja | Titel. |
| `start_at` | `string` | ja | Beginn (ISO 8601). |
| `end_at` | `string` | nein | Ende (Standard: +1 Stunde). |
| `all_day` | `boolean` | nein | Ganztägig. |
| `description` | `string` | nein | Beschreibung. |
| `location` | `string` | nein | Ort oder Link. |
| `contact_id` | `integer` | nein | Verknüpfter Kontakt. |
| `attendee_emails` | `array<string>` | nein | Teilnehmer-Adressen. |
| `phone` | `string` | nein | Telefon. |
| `color` | `string` | nein | Farbe: violet, coral, mint, gold, blue, pink, green, lilac. Werte: `violet`, `coral`, `mint`, `gold`, `blue`, `pink`, `green`, `lilac`. |
| `reminder_minutes` | `integer` | nein | Erinnerung x Minuten vorher (-1 = keine, 0 = zum Beginn, max. 40320). |
| `remind_attendees` | `boolean` | nein | Auch Teilnehmer erinnern. |
| `status` | `string` | nein | planned \| done \| cancelled \| no_show Werte: `planned`, `done`, `cancelled`, `no_show`. |

```bash
curl -X POST https://persolz.com/api/v1/appointments \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Erstgespräch Anna Schmidt",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "contact_id": 42,
    "location": "Zoom"
  }'
```

**Antwort 201** – Angelegt · [Termin-Objekt](#obj-appointment)

```json
{
  "data": {
    "id": 55,
    "title": "Erstgespräch Anna Schmidt",
    "description": "Kennenlernen per Video",
    "location": "Zoom",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "all_day": false,
    "contact_id": 42,
    "contact": {
      "id": 42,
      "name": "Anna Schmidt",
      "email": "anna@beispiel.de",
      "phone": "+49 30 1234567"
    },
    "attendees": ["anna@beispiel.de"],
    "phone": "",
    "color": "violet",
    "status": "planned",
    "status_label": "Geplant",
    "reminder_minutes": 60,
    "remind_attendees": true,
    "source": "api",
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="appointments-get"></a>
#### Termin abrufen

`GET /api/v1/appointments/{id}`

Scope: `read` · Tarif-Funktion: `calendar`

Ein Termin.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/appointments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Termin-Objekt](#obj-appointment)

```json
{
  "data": {
    "id": 55,
    "title": "Erstgespräch Anna Schmidt",
    "description": "Kennenlernen per Video",
    "location": "Zoom",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "all_day": false,
    "contact_id": 42,
    "contact": {
      "id": 42,
      "name": "Anna Schmidt",
      "email": "anna@beispiel.de",
      "phone": "+49 30 1234567"
    },
    "attendees": ["anna@beispiel.de"],
    "phone": "",
    "color": "violet",
    "status": "planned",
    "status_label": "Geplant",
    "reminder_minutes": 60,
    "remind_attendees": true,
    "source": "api",
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="appointments-update"></a>
#### Termin ändern

`PATCH /api/v1/appointments/{id}`

Scope: `write` · Tarif-Funktion: `calendar`

Ändert nur die übergebenen Felder (Webhook appointment.updated).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `title` | `string` | ja | Titel. |
| `start_at` | `string` | ja | Beginn (ISO 8601). |
| `end_at` | `string` | nein | Ende (Standard: +1 Stunde). |
| `all_day` | `boolean` | nein | Ganztägig. |
| `description` | `string` | nein | Beschreibung. |
| `location` | `string` | nein | Ort oder Link. |
| `contact_id` | `integer` | nein | Verknüpfter Kontakt. |
| `attendee_emails` | `array<string>` | nein | Teilnehmer-Adressen. |
| `phone` | `string` | nein | Telefon. |
| `color` | `string` | nein | Farbe: violet, coral, mint, gold, blue, pink, green, lilac. Werte: `violet`, `coral`, `mint`, `gold`, `blue`, `pink`, `green`, `lilac`. |
| `reminder_minutes` | `integer` | nein | Erinnerung x Minuten vorher (-1 = keine, 0 = zum Beginn, max. 40320). |
| `remind_attendees` | `boolean` | nein | Auch Teilnehmer erinnern. |
| `status` | `string` | nein | planned \| done \| cancelled \| no_show Werte: `planned`, `done`, `cancelled`, `no_show`. |

```bash
curl -X PATCH https://persolz.com/api/v1/appointments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "start_at": "2026-10-14T11:00:00+02:00",
    "end_at": "2026-10-14T11:30:00+02:00"
  }'
```

**Antwort 200** – OK · [Termin-Objekt](#obj-appointment)

```json
{
  "data": {
    "id": 55,
    "title": "Erstgespräch Anna Schmidt",
    "description": "Kennenlernen per Video",
    "location": "Zoom",
    "start_at": "2026-10-14T10:00:00+02:00",
    "end_at": "2026-10-14T10:30:00+02:00",
    "all_day": false,
    "contact_id": 42,
    "contact": {
      "id": 42,
      "name": "Anna Schmidt",
      "email": "anna@beispiel.de",
      "phone": "+49 30 1234567"
    },
    "attendees": ["anna@beispiel.de"],
    "phone": "",
    "color": "violet",
    "status": "planned",
    "status_label": "Geplant",
    "reminder_minutes": 60,
    "remind_attendees": true,
    "source": "api",
    "created_at": "2026-10-09T14:05:00+02:00",
    "updated_at": "2026-10-09T14:05:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="appointments-delete"></a>
#### Termin löschen

`DELETE /api/v1/appointments/{id}`

Scope: `write` · Tarif-Funktion: `calendar`

Löscht den Termin.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/appointments/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-inbox"></a>
### Posteingang

Verbundene IMAP-Postfächer: Mails lesen und sortieren, antworten, KI-Entwürfe schreiben lassen und freigeben, Kennzahlen abrufen.

Der Posteingang braucht die Tarif-Funktion `inbox` und ein in der App verbundenes IMAP-Postfach. Die API arbeitet mit dem Zwischenspeicher (neue Mails sofort: `POST /mailboxes/{id}/sync`); Inhalte gibt es nur einzeln mit `include_body=true`. Senden braucht den Scope `send`, KI-Entwürfe den Scope `ai` und die Funktion `ai`. Anleitung: [Posteingang per API](#posteingang).

<a id="obj-mailbox"></a>
#### Das Postfach-Objekt

Ein verbundenes IMAP-Postfach mit Abruf-Status und KI-Einstellungen – das Passwort gibt die API nie heraus.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `label` | `string` | Bezeichnung des Postfachs in der App. |
| `email` | `string` | Adresse des Postfachs (Absender der Antworten). |
| `display_name` | `string` | Absendername. |
| `color` | `string` | Farbe als Hex-Wert (#RRGGBB). |
| `provider` | `string` | Anbieter-Vorlage: gmail, outlook, microsoft365, icloud, gmx, webde, tonline, ionos, strato, … oder custom. |
| `provider_name` | `string` | Anbieter in Worten. |
| `imap_host` | `string` | IMAP-Server. |
| `imap_port` | `integer` | IMAP-Port (993 = SSL/TLS, 143 = STARTTLS). |
| `imap_encryption` | `string` | Verschlüsselung: ssl (SSL/TLS) oder tls (STARTTLS). |
| `imap_username` | `string` | Benutzername beim IMAP-Server. Das Passwort wird nie ausgegeben. |
| `smtp_account_id` | `integer` | SMTP-Konto, über das Antworten gesendet werden (GET /smtp), oder null. |
| `sync_enabled` | `boolean` | Neue Mails werden automatisch abgerufen. |
| `status` | `string` | new (noch nie abgerufen), ok, syncing (Abruf läuft), error (siehe last_error) oder paused. |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `last_error` | `string · null` | Letzter Fehler beim Abruf oder null. |
| `last_error_hint` | `string · null` | Tipp zur Behebung des letzten Fehlers oder null. |
| `last_sync_at` | `string (Datum/Zeit)` | Letzter erfolgreicher Abruf oder null. |
| `unread` | `integer` | Ungelesene Mails im Posteingang. |
| `ai` | `object` | KI-Einstellungen des Postfachs. |
| `ai.triage` | `boolean` | Neue Mails werden eingeordnet (Kategorie, Priorität, Zusammenfassung). |
| `ai.mode` | `string` | off (aus), suggest (Vorschläge auf Knopfdruck), approve (KI schreibt, Du gibst frei) oder auto (Autopilot). |
| `ai.mode_label` | `string` | KI-Modus in Worten. |
| `ai.model` | `string` | KI-Modell für Antworten: sonnet oder haiku. |
| `ai.auto_categories` | `array<string>` | Kategorien, die der Autopilot selbst beantworten darf. |
| `ai.min_confidence` | `integer` | Mindest-Sicherheit der KI (0–100) für automatische Antworten. |
| `ai.disclosure` | `boolean` | Automatische Antworten tragen einen KI-Hinweis. |
| `ai.reply_delay_minutes` | `integer` | Wartezeit in Minuten, bevor eine Autopilot-Antwort rausgeht – so lange kannst Du sie stoppen. |
| `ai.daily_cap` | `integer` | Max. Autopilot-Antworten pro Tag für dieses Postfach (zusätzlich gilt das Limit Deines Tarifs). |
| `ai.business_hours` | `object` | Geschäftszeiten für automatische Antworten oder null (= jederzeit). |
| `ai.business_hours.days` | `array<integer>` | Wochentage (1 = Montag … 7 = Sonntag). |
| `ai.business_hours.from` | `string` | Beginn (HH:MM). |
| `ai.business_hours.to` | `string` | Ende (HH:MM). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 2,
  "label": "Kartenherz Kundenservice",
  "email": "hallo@kartenherz.com",
  "display_name": "Kartenherz",
  "color": "#5B3DF5",
  "provider": "strato",
  "provider_name": "Strato",
  "imap_host": "imap.strato.de",
  "imap_port": 993,
  "imap_encryption": "ssl",
  "imap_username": "hallo@kartenherz.com",
  "smtp_account_id": 1,
  "sync_enabled": true,
  "status": "ok",
  "status_label": "Verbunden",
  "last_error": null,
  "last_error_hint": null,
  "last_sync_at": "2026-10-09T14:05:00+02:00",
  "unread": 3,
  "ai": {
    "triage": true,
    "mode": "approve",
    "mode_label": "Mit Freigabe – KI schreibt Antworten, Du gibst sie frei",
    "model": "sonnet",
    "auto_categories": ["lead", "customer", "appointment"],
    "min_confidence": 85,
    "disclosure": true,
    "reply_delay_minutes": 15,
    "daily_cap": 20,
    "business_hours": {
      "days": [1, 2, 3, 4, 5],
      "from": "08:00",
      "to": "18:00"
    }
  },
  "created_at": "2026-10-01T09:00:00+02:00"
}
```

<a id="obj-inbox-message"></a>
#### Das Mail-Objekt (Posteingang)

Eine Mail aus dem Zwischenspeicher des Posteingangs mit KI-Einordnung – ohne Inhalt (den liefert `GET /inbox/messages/{id}?include_body=true`). Auch die Webhooks `inbox.*` enthalten dieses Objekt.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `mailbox_id` | `integer` | ID des Postfachs. |
| `mailbox_email` | `string` | Adresse des Postfachs. |
| `folder` | `string` | Ordner in Worten (z. B. Posteingang). |
| `folder_role` | `string` | Rolle des Ordners: inbox, sent, archive, trash, junk … oder null. |
| `thread_key` | `string` | Kennung der Unterhaltung (gleich für alle Mails eines Verlaufs). |
| `message_id` | `string` | Message-ID-Header der Mail oder null. |
| `direction` | `string` | in (empfangen) oder out (von Dir gesendet). |
| `from` | `object` | Absender. |
| `from.email` | `string` | E-Mail-Adresse des Absenders. |
| `from.name` | `string` | Name des Absenders (kann leer sein). |
| `to` | `array<object>` | Empfänger (Liste aus {email, name}). |
| `to[].email` | `string` | E-Mail-Adresse. |
| `to[].name` | `string` | Name (kann leer sein). |
| `cc` | `array` | Kopie-Empfänger (Liste aus {email, name}). |
| `subject` | `string` | Betreff. |
| `snippet` | `string` | Vorschau (Anfang des Textes). |
| `received_at` | `string (Datum/Zeit)` | Eingang (ISO 8601). |
| `is_seen` | `boolean` | Gelesen. |
| `is_flagged` | `boolean` | Markiert (Stern). |
| `is_answered` | `boolean` | Beantwortet. |
| `has_attachments` | `boolean` | Hat Anhänge. |
| `has_calendar` | `boolean` | Enthält eine Kalendereinladung (.ics). |
| `category` | `string` | KI-Kategorie: lead, customer, appointment, invoice, order, campaign_reply, partner, personal, newsletter, notification, unsubscribe, complaint, spam, other – oder null (noch nicht eingeordnet). |
| `category_label` | `string` | Kategorie in Worten. |
| `priority` | `string` | Priorität laut KI: high, normal oder low – oder null. |
| `sentiment` | `string` | Stimmung laut KI: positive, neutral oder negative – oder null. |
| `intent` | `string` | Anliegen in wenigen Worten (KI) oder null. |
| `summary` | `string` | Zusammenfassung in einem Satz (KI) oder null. |
| `needs_reply` | `boolean` | Die Mail braucht laut KI eine Antwort (null = noch nicht eingeordnet). |
| `contact_id` | `integer` | Zugeordneter Kontakt (Absender ist in Deinen Kontakten) oder null. |
| `campaign_id` | `integer · null` | Kampagne, auf die geantwortet wurde (Leser-Antwort), oder null. |
| `labels` | `array<string>` | Eigene Labels. |
| `snoozed_until` | `string · null` | Zurückgestellt bis (ISO 8601) oder null. |
| `follow_up_at` | `string · null` | Erinnerung, falls bis dahin keine Antwort kommt (ISO 8601), oder null. |
| `replied_at` | `string · null` | Zeitpunkt Deiner Antwort aus Persolz oder null. |
| `has_ai_draft` | `boolean` | Es gibt einen KI-Entwurf zu dieser Mail (zur Freigabe oder als Vorschlag). |

```json
{
  "id": 9120,
  "mailbox_id": 2,
  "mailbox_email": "hallo@kartenherz.com",
  "folder": "Posteingang",
  "folder_role": "inbox",
  "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
  "message_id": "<CAF-anfrage-4711@mail.beispiel.de>",
  "direction": "in",
  "from": {
    "email": "anna@beispiel.de",
    "name": "Anna Schmidt"
  },
  "to": [
    {
      "email": "hallo@kartenherz.com",
      "name": "Kartenherz"
    }
  ],
  "cc": [],
  "subject": "Frage zu Hochzeitskarten",
  "snippet": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
  "received_at": "2026-10-09T14:05:00+02:00",
  "is_seen": false,
  "is_flagged": false,
  "is_answered": false,
  "has_attachments": false,
  "has_calendar": false,
  "category": "lead",
  "category_label": "Leads & Anfragen",
  "priority": "high",
  "sentiment": "positive",
  "intent": "Angebot für 80 Hochzeitskarten",
  "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
  "needs_reply": true,
  "contact_id": 42,
  "campaign_id": null,
  "labels": ["Hochzeit"],
  "snoozed_until": null,
  "follow_up_at": null,
  "replied_at": null,
  "has_ai_draft": true
}
```

<a id="obj-inbox-draft"></a>
#### Das Entwurfs-Objekt (Posteingang)

Eine ausgehende Mail: Entwurf, KI-Entwurf zur Freigabe, geplante, gesendete oder fehlgeschlagene Mail.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `mailbox_id` | `integer` | Absender-Postfach. |
| `kind` | `string` | reply, reply_all, forward, new oder auto (Autopilot). |
| `kind_label` | `string` | Art in Worten. |
| `status` | `string` | draft, pending (wartet auf Freigabe), scheduled (geplant), sending, sent, failed, rejected oder cancelled. |
| `status_label` | `string` | Status in Worten (Deutsch). |
| `source` | `string` | user (von Dir), ai (KI-Entwurf) oder rule (Regel). |
| `reply_to_message_id` | `integer` | Bezugsmail (Antwort/Weiterleitung) oder null. |
| `thread_key` | `string` | Kennung der Unterhaltung (gleich für alle Mails eines Verlaufs). |
| `to` | `array<object>` | Empfänger (Liste aus {email, name}). |
| `to[].email` | `string` | E-Mail-Adresse. |
| `to[].name` | `string` | Name (kann leer sein). |
| `cc` | `array` | Kopie-Empfänger (Liste aus {email, name}). |
| `bcc` | `array` | Blindkopie-Empfänger (Liste aus {email, name}). |
| `subject` | `string` | Betreff. |
| `html` | `string` | Inhalt als HTML. |
| `text` | `string` | Inhalt als Text. |
| `attachment_ids` | `array` | Anhänge aus der Anhang-Cloud. |
| `mail_attachment_ids` | `array` | Mitgesendete Anhänge der Originalmail (Weiterleitung). |
| `send_at` | `string (Datum/Zeit)` | Geplanter Versand (ISO 8601) oder null. |
| `sent_at` | `string (Datum/Zeit)` | Zeitpunkt des Versands oder null. |
| `sent_message_id` | `string` | Message-ID der gesendeten Mail oder null. |
| `error` | `string · null` | Fehler beim Versand oder null. |
| `ai` | `string · null` | KI-Angaben (nur bei KI-Entwürfen, sonst null). |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string (Datum/Zeit)` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |

```json
{
  "id": 5502,
  "mailbox_id": 2,
  "kind": "reply",
  "kind_label": "Antwort",
  "status": "sent",
  "status_label": "Gesendet",
  "source": "user",
  "reply_to_message_id": 9120,
  "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
  "to": [
    {
      "email": "anna@beispiel.de",
      "name": "Anna Schmidt"
    }
  ],
  "cc": [],
  "bcc": [],
  "subject": "Re: Frage zu Hochzeitskarten",
  "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
  "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
  "attachment_ids": [],
  "mail_attachment_ids": [],
  "send_at": "2026-10-09T14:21:00+02:00",
  "sent_at": "2026-10-09T14:21:04+02:00",
  "sent_message_id": "<6f1c0d9a2b7e4c1f@kartenherz.com>",
  "error": null,
  "ai": null,
  "created_at": "2026-10-09T14:06:10+02:00",
  "updated_at": "2026-10-09T14:06:10+02:00"
}
```

<a id="mailboxes-list"></a>
#### Postfächer auflisten

`GET /api/v1/mailboxes`

Scope: `read` · Tarif-Funktion: `inbox`

Alle verbundenen IMAP-Postfächer mit Status, letztem Abruf, ungelesenen Mails im Posteingang und den KI-Einstellungen. Passwörter werden nie ausgegeben.

```bash
curl https://persolz.com/api/v1/mailboxes \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Postfach-Objekt](#obj-mailbox)

```json
{
  "data": [
    {
      "id": 2,
      "label": "Kartenherz Kundenservice",
      "email": "hallo@kartenherz.com",
      "display_name": "Kartenherz",
      "color": "#5B3DF5",
      "provider": "strato",
      "provider_name": "Strato",
      "imap_host": "imap.strato.de",
      "imap_port": 993,
      "imap_encryption": "ssl",
      "imap_username": "hallo@kartenherz.com",
      "smtp_account_id": 1,
      "sync_enabled": true,
      "status": "ok",
      "status_label": "Verbunden",
      "last_error": null,
      "last_error_hint": null,
      "last_sync_at": "2026-10-09T14:05:00+02:00",
      "unread": 3,
      "ai": {
        "triage": true,
        "mode": "approve",
        "mode_label": "Mit Freigabe – KI schreibt Antworten, Du gibst sie frei",
        "model": "sonnet",
        "auto_categories": ["lead", "customer", "appointment"],
        "min_confidence": 85,
        "disclosure": true,
        "reply_delay_minutes": 15,
        "daily_cap": 20,
        "business_hours": {
          "days": [1, 2, 3, 4, 5],
          "from": "08:00",
          "to": "18:00"
        }
      },
      "created_at": "2026-10-01T09:00:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="mailboxes-get"></a>
#### Postfach abrufen

`GET /api/v1/mailboxes/{id}`

Scope: `read` · Tarif-Funktion: `inbox`

Ein Postfach mit seinen Ordnern (Rolle, Synchronisation, ungelesen/gesamt im Zwischenspeicher).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/mailboxes/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Postfach-Objekt](#obj-mailbox)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.folders` | `array<object>` | Ordner des Postfachs. |
| `data.folders[].id` | `integer` | Eindeutige ID. |
| `data.folders[].name` | `string` | Ordnername auf dem Server. |
| `data.folders[].display_name` | `string` | Ordnername in Worten. |
| `data.folders[].role` | `string` | Rolle: inbox, sent, drafts, archive, trash, junk, all oder null. |
| `data.folders[].is_synced` | `boolean` | Der Ordner wird zwischengespeichert (Liste, Suche, Ansichten). |
| `data.folders[].unread` | `integer` | Ungelesene Mails im Zwischenspeicher. |
| `data.folders[].total` | `integer` | Mails im Zwischenspeicher. |

```json
{
  "data": {
    "id": 2,
    "label": "Kartenherz Kundenservice",
    "email": "hallo@kartenherz.com",
    "display_name": "Kartenherz",
    "color": "#5B3DF5",
    "provider": "strato",
    "provider_name": "Strato",
    "imap_host": "imap.strato.de",
    "imap_port": 993,
    "imap_encryption": "ssl",
    "imap_username": "hallo@kartenherz.com",
    "smtp_account_id": 1,
    "sync_enabled": true,
    "status": "ok",
    "status_label": "Verbunden",
    "last_error": null,
    "last_error_hint": null,
    "last_sync_at": "2026-10-09T14:05:00+02:00",
    "unread": 3,
    "ai": {
      "triage": true,
      "mode": "approve",
      "mode_label": "Mit Freigabe – KI schreibt Antworten, Du gibst sie frei",
      "model": "sonnet",
      "auto_categories": ["lead", "customer", "appointment"],
      "min_confidence": 85,
      "disclosure": true,
      "reply_delay_minutes": 15,
      "daily_cap": 20,
      "business_hours": {
        "days": [1, 2, 3, 4, 5],
        "from": "08:00",
        "to": "18:00"
      }
    },
    "created_at": "2026-10-01T09:00:00+02:00",
    "folders": [
      {
        "id": 11,
        "name": "INBOX",
        "display_name": "Posteingang",
        "role": "inbox",
        "is_synced": true,
        "unread": 3,
        "total": 214
      },
      {
        "id": 12,
        "name": "Gesendet",
        "display_name": "Gesendet",
        "role": "sent",
        "is_synced": true,
        "unread": 0,
        "total": 96
      }
    ]
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="mailboxes-sync"></a>
#### Postfach jetzt abrufen

`POST /api/v1/mailboxes/{id}/sync`

Scope: `write` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Ruft neue Mails sofort ab (statt auf den Cron zu warten; max. 15 Sekunden, große Postfächer werden in mehreren Läufen vollständig). Ohne KI-Funktion werden neue und noch nicht eingeordnete Mails dabei gleich regelbasiert sortiert (ohne KI-Kosten). Höchstens 8 Abrufe pro Minute.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/mailboxes/42/sync \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.mailbox_id` | `integer` | ID des Postfachs. |
| `data.ok` | `boolean` | Abruf erfolgreich. |
| `data.new` | `integer` | Neu abgerufene Mails. |
| `data.updated` | `integer` | Mails mit geänderten Markierungen (gelesen, markiert …). |
| `data.removed` | `integer` | Auf dem Server gelöschte oder verschobene Mails. |
| `data.partial` | `boolean` | Zeitbudget erreicht – der Rest folgt beim nächsten Abruf. |
| `data.busy` | `boolean` | Ein anderer Abruf läuft gerade. |
| `data.error` | `string · null` | Fehlermeldung oder null. |
| `data.hint` | `string · null` | Tipp zur Behebung oder null. |
| `data.duration_ms` | `integer` | Dauer des Abrufs in Millisekunden. |
| `data.last_sync_at` | `string (Datum/Zeit)` | Letzter erfolgreicher Abruf oder null. |

```json
{
  "data": {
    "mailbox_id": 2,
    "ok": true,
    "new": 4,
    "updated": 1,
    "removed": 0,
    "partial": false,
    "busy": false,
    "error": null,
    "hint": null,
    "duration_ms": 1830,
    "last_sync_at": "2026-10-09T14:05:00+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="inbox-messages"></a>
#### Mails auflisten

`GET /api/v1/inbox/messages`

Scope: `read` · Tarif-Funktion: `inbox` · paginiert

Mails aus dem Zwischenspeicher, neueste zuerst – ohne Inhalt (den liefert GET /inbox/messages/{id}?include_body=true). Ansichten wie in der App: focus (braucht eine Antwort, letzte 60 Tage), all (Posteingang), unread, flagged, waiting (Erinnerung gesetzt), snoozed (zurückgestellt), approvals (KI-Entwurf wartet), sent, archive, trash, spam, any (alle Ordner außer Papierkorb/Spam). has_ai_draft = es gibt eine KI-Antwort zur Freigabe oder als Vorschlag.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `view` | `string` | nein | Ansicht (Standard all). Werte: `focus`, `all`, `unread`, `flagged`, `waiting`, `snoozed`, `approvals`, `sent`, `archive`, `trash`, `spam`, `any`. |
| `mailbox_id` | `integer` | nein | Nur dieses Postfach. |
| `folder_id` | `integer` | nein | Nur dieser Ordner (siehe GET /mailboxes/{id}); ersetzt view. |
| `category` | `string` | nein | KI-Kategorie, z. B. lead, customer, invoice, campaign_reply (Leser-Antworten). Werte: `lead`, `customer`, `appointment`, `invoice`, `order`, `campaign_reply`, `partner`, `personal`, `newsletter`, `notification`, `unsubscribe`, `complaint`, `spam`, `other`. |
| `q` | `string` | nein | Suche in Betreff, Absender, Vorschau und Text. |
| `contact_id` | `integer` | nein | Nur Mails dieses Kontakts. |
| `since` | `string` | nein | Nur Mails, die ab diesem Zeitpunkt eingegangen sind (ISO 8601 oder YYYY-MM-DD) – ideal zum Abgleichen. |
| `label` | `string` | nein | Nur Mails mit diesem Label. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/inbox/messages?view=focus&category=lead" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Mail-Objekt (Posteingang)](#obj-inbox-message)

```json
{
  "data": [
    {
      "id": 9120,
      "mailbox_id": 2,
      "mailbox_email": "hallo@kartenherz.com",
      "folder": "Posteingang",
      "folder_role": "inbox",
      "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
      "message_id": "<CAF-anfrage-4711@mail.beispiel.de>",
      "direction": "in",
      "from": {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      },
      "to": [
        {
          "email": "hallo@kartenherz.com",
          "name": "Kartenherz"
        }
      ],
      "cc": [],
      "subject": "Frage zu Hochzeitskarten",
      "snippet": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
      "received_at": "2026-10-09T14:05:00+02:00",
      "is_seen": false,
      "is_flagged": false,
      "is_answered": false,
      "has_attachments": false,
      "has_calendar": false,
      "category": "lead",
      "category_label": "Leads & Anfragen",
      "priority": "high",
      "sentiment": "positive",
      "intent": "Angebot für 80 Hochzeitskarten",
      "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
      "needs_reply": true,
      "contact_id": 42,
      "campaign_id": null,
      "labels": ["Hochzeit"],
      "snoozed_until": null,
      "follow_up_at": null,
      "replied_at": null,
      "has_ai_draft": true
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="inbox-message"></a>
#### Mail abrufen

`GET /api/v1/inbox/messages/{id}`

Scope: `read` · Tarif-Funktion: `inbox`

Eine Mail mit Anhängen (Metadaten), KI-Auswertung (Aktionen, Risiken), Verlauf und Entwürfen. Mit include_body=true auch der Inhalt: text und html (bereits bereinigt – externe Bilder sind blockiert, solange images nicht true ist). Ist der Inhalt nicht mehr im Zwischenspeicher, wird er vom Mailserver geladen. Der Abruf markiert die Mail nicht als gelesen (dafür POST /inbox/messages/actions mit action=read).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `include_body` | `boolean` | nein | Inhalt (text + html) mitliefern. |
| `images` | `boolean` | nein | Externe Bilder im HTML wiederherstellen (Vorsicht: Tracking-Pixel). |

```bash
curl "https://persolz.com/api/v1/inbox/messages/42?include_body=true" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Mail-Objekt (Posteingang)](#obj-inbox-message)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.ai` | `object` | KI-Einordnung der Mail. |
| `data.ai.status` | `string` | Stand der KI-Einordnung: pending, done, skipped oder error. |
| `data.ai.actions` | `array<object>` | Vorgeschlagene nächste Schritte. |
| `data.ai.actions[].key` | `string` | Schlüssel der Aktion (z. B. reply, create_contact, book_appointment). |
| `data.ai.actions[].label` | `string` | Aktion in Worten. |
| `data.ai.risk_flags` | `array` | Risiken: legal, money, medical, threat, prompt_injection, personal_data, unclear. |
| `data.ai.risk_flag_labels` | `array` | Risiken in Worten. |
| `data.ai.language` | `string` | Sprache der Mail (z. B. de, en) oder null. |
| `data.ai.confidence` | `integer` | Sicherheit der Einordnung (0–100) oder null. |
| `data.attachments` | `array<object>` | Anhänge (nur Metadaten). |
| `data.attachments[].id` | `integer` | Eindeutige ID. |
| `data.attachments[].filename` | `string` | Dateiname. |
| `data.attachments[].mime` | `string` | MIME-Typ. |
| `data.attachments[].size` | `integer` | Größe in Bytes. |
| `data.attachments[].is_inline` | `boolean` | Eingebettetes Bild (cid:). |
| `data.attachments[].content_id` | `integer · null` | Content-ID eingebetteter Bilder oder null. |
| `data.thread` | `array<object>` | Verlauf: alle Mails dieser Unterhaltung (ältere zuerst). |
| `data.thread[].id` | `integer` | Eindeutige ID. |
| `data.thread[].direction` | `string` | in (empfangen) oder out (gesendet). |
| `data.thread[].subject` | `string` | Betreff. |
| `data.thread[].from` | `object` | Absender. |
| `data.thread[].from.email` | `string` | E-Mail-Adresse des Absenders. |
| `data.thread[].from.name` | `string` | Name des Absenders (kann leer sein). |
| `data.thread[].received_at` | `string (Datum/Zeit)` | Eingang (ISO 8601). |
| `data.thread[].snippet` | `string` | Vorschau (Anfang des Textes). |
| `data.drafts` | `array<object>` | Entwürfe und ausgehende Mails zu dieser Mail (neueste zuerst). |
| `data.drafts[].id` | `integer` | Eindeutige ID. |
| `data.drafts[].kind` | `string` | reply, reply_all, forward, new oder auto. |
| `data.drafts[].status` | `string` | draft, pending, scheduled, sending, sent, failed, rejected oder cancelled. |
| `data.drafts[].source` | `string` | user (von Dir), ai (KI) oder rule (Regel). |
| `data.drafts[].subject` | `string` | Betreff. |
| `data.drafts[].send_at` | `string · null` | Geplanter Versand oder null. |
| `data.body` | `object` | Inhalt (nur mit include_body=true). |
| `data.body.text` | `string` | Inhalt als Text. |
| `data.body.html` | `string` | Inhalt als bereinigtes HTML (ohne Skripte; externe Bilder blockiert, solange images nicht true ist). |
| `data.body.has_remote_images` | `boolean` | Die Mail lädt externe Bilder (Tracking möglich). |
| `data.body.missing` | `boolean` | Der Inhalt ist auf dem Server nicht mehr vorhanden. |
| `data.body.error` | `string · null` | Fehler beim Nachladen vom Server oder null. |

```json
{
  "data": {
    "id": 9120,
    "mailbox_id": 2,
    "mailbox_email": "hallo@kartenherz.com",
    "folder": "Posteingang",
    "folder_role": "inbox",
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "message_id": "<CAF-anfrage-4711@mail.beispiel.de>",
    "direction": "in",
    "from": {
      "email": "anna@beispiel.de",
      "name": "Anna Schmidt"
    },
    "to": [
      {
        "email": "hallo@kartenherz.com",
        "name": "Kartenherz"
      }
    ],
    "cc": [],
    "subject": "Frage zu Hochzeitskarten",
    "snippet": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
    "received_at": "2026-10-09T14:05:00+02:00",
    "is_seen": false,
    "is_flagged": false,
    "is_answered": false,
    "has_attachments": false,
    "has_calendar": false,
    "category": "lead",
    "category_label": "Leads & Anfragen",
    "priority": "high",
    "sentiment": "positive",
    "intent": "Angebot für 80 Hochzeitskarten",
    "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
    "needs_reply": true,
    "contact_id": 42,
    "campaign_id": null,
    "labels": ["Hochzeit"],
    "snoozed_until": null,
    "follow_up_at": null,
    "replied_at": null,
    "ai": {
      "status": "done",
      "actions": [
        {
          "key": "reply",
          "label": "Antwort entwerfen"
        },
        {
          "key": "create_contact",
          "label": "Kontakt anlegen"
        }
      ],
      "risk_flags": [],
      "risk_flag_labels": [],
      "language": "de",
      "confidence": 92
    },
    "attachments": [
      {
        "id": 301,
        "filename": "Wunschmotiv.jpg",
        "mime": "image/jpeg",
        "size": 184220,
        "is_inline": false,
        "content_id": null
      }
    ],
    "thread": [
      {
        "id": 9120,
        "direction": "in",
        "subject": "Frage zu Hochzeitskarten",
        "from": {
          "email": "anna@beispiel.de",
          "name": "Anna Schmidt"
        },
        "received_at": "2026-10-09T14:05:00+02:00",
        "snippet": "Hallo, wir heiraten im Mai …"
      }
    ],
    "drafts": [
      {
        "id": 5502,
        "kind": "reply",
        "status": "pending",
        "source": "ai",
        "subject": "Re: Frage zu Hochzeitskarten",
        "send_at": null
      }
    ],
    "body": {
      "text": "Hallo,\n\nwir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung …",
      "html": "<p>Hallo,</p><p>wir heiraten im Mai …</p>",
      "has_remote_images": false,
      "missing": false,
      "error": null
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="inbox-actions"></a>
#### Mails bearbeiten (Massenaktion)

`POST /api/v1/inbox/messages/actions`

Scope: `write` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Dieselben Aktionen wie in der App, für bis zu 500 Mails auf einmal – Verschieben, Löschen und Markierungen werden auf den Mailserver übertragen. params: folder_id (move), until (snooze), at (follow_up = „Erinnern, falls keine Antwort kommt“), labels + mode set|add|remove (label), category (set_category). delete_forever löscht endgültig – auch auf dem Server.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `ids` | `array<integer>` | ja | IDs der Mails (max. 500). |
| `action` | `string` | ja | Aktion. Werte: `read`, `unread`, `flag`, `unflag`, `archive`, `trash`, `spam`, `notspam`, `move`, `restore`, `delete_forever`, `snooze`, `unsnooze`, `follow_up`, `clear_follow_up`, `label`, `set_category`. |
| `params` | `object` | nein | Parameter der Aktion (siehe Beschreibung). |

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/actions \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [9120, 9121],
    "action": "snooze",
    "params": {
      "until": "2026-10-12T09:00:00+02:00"
    }
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.action` | `string` | Ausgeführte Aktion. |
| `data.affected` | `integer` | Geänderte Mails. |
| `data.ids` | `array<integer>` | IDs der bearbeiteten Mails. |
| `data.not_found` | `array` | IDs, die es (für Dich) nicht gibt. |
| `data.warning` | `string · null` | Hinweis, wenn der Mailserver die Änderung nicht übernommen hat (der nächste Abruf gleicht ab), sonst null. |

```json
{
  "data": {
    "action": "snooze",
    "affected": 2,
    "ids": [9120, 9121],
    "not_found": [],
    "warning": null
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="inbox-reply"></a>
#### Auf eine Mail antworten

`POST /api/v1/inbox/messages/{id}/reply`

Scope: `send` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Antwortet über das Postfach der Mail (SMTP-Konto des Postfachs) – mit In-Reply-To/References, Signatur und zitiertem Original (quote=false lässt das Zitat weg). Empfänger und Betreff („Re: …“) werden wie in der App vorbelegt und können überschrieben werden; reply_all=true antwortet allen. Ohne send_at wird sofort gesendet; die Kopie landet im Ordner „Gesendet“. Schlägt der Versand fehl, antwortet die API mit 503 – der Entwurf bleibt (draft_id) und kann mit POST /inbox/drafts/{id}/send erneut gesendet werden.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `html` | `string` | nein | Inhalt als HTML (wird bereinigt; Skripte, Formulare & Co. werden entfernt). Signatur des Postfachs wird automatisch ergänzt. |
| `text` | `string` | nein | Alternativ: reiner Text (Links werden verlinkt). |
| `attachment_ids` | `array<integer>` | nein | Anhänge aus der Anhang-Cloud (GET /attachments). |
| `send_at` | `string` | nein | Später senden (ISO 8601, mind. 1 Minute, max. 1 Jahr in der Zukunft). Leer = sofort. |
| `to` | `string|array` | nein | Empfänger: E-Mail, kommagetrennte E-Mails („Name &lt;mail@…&gt;“ erlaubt) oder Liste aus E-Mails bzw. Objekten {email, name}. |
| `cc` | `string|array` | nein | Kopie (wie to). |
| `bcc` | `string|array` | nein | Blindkopie (wie to). |
| `subject` | `string` | nein | Betreff (Standard: „Re: &lt;Betreff&gt;“). |
| `reply_all` | `boolean` | nein | Allen antworten (To + Cc der Mail, ohne Deine eigenen Adressen). |
| `quote` | `boolean` | nein | Original zitieren (Standard true). |

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/42/reply \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<p>Hallo Anna,</p><p>herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € und sind in 10 Tagen bei Dir.</p><p>Viele Grüße</p>"
  }'
```

**Antwort 201** – Gesendet bzw. geplant · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "sent",
    "status_label": "Gesendet",
    "source": "user",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": "2026-10-09T14:21:00+02:00",
    "sent_at": "2026-10-09T14:21:04+02:00",
    "sent_message_id": "<6f1c0d9a2b7e4c1f@kartenherz.com>",
    "error": null,
    "ai": null,
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="inbox-aidraft"></a>
#### KI-Antwort entwerfen

`POST /api/v1/inbox/messages/{id}/ai-draft`

Scope: `ai` · Tarif-Funktion: `inbox`, `ai` · unterstützt `Idempotency-Key`

Die KI schreibt eine Antwort im Stil des Postfachs (Ton, Wissen für die KI, Kontaktdaten, Anrede Du/Sie und Sprache des Absenders) – inklusive Selbsteinschätzung (confidence), Risiken und Begründung. Die Mail wird als nicht vertrauenswürdige Eingabe behandelt (Schutz vor Prompt-Injection). Ein unveränderter KI-Entwurf zur selben Mail wird ohne neue KI-Anfrage wiederverwendet (cached), außer bei fresh=true oder eigenen Vorgaben. save=draft speichert ihn als Vorschlag („Antwort bereit“), save=approval legt ihn in die Freigaben – senden mit POST /inbox/approvals/{id}/approve. Verbraucht KI-Guthaben.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `instruction` | `string` | nein | Eigene Vorgabe (max. 600 Zeichen), z. B. „Biete einen Rückruf an“. |
| `tone` | `string` | nein | Ton: auto, freundlich, herzlich, locker, foermlich, sachlich, knapp. |
| `length` | `string` | nein | Länge: auto, kurz, mittel, lang. |
| `include_slots` | `boolean` | nein | Freie Termine aus dem Terminbuch vorschlagen. |
| `model` | `string` | nein | sonnet (Standard des Postfachs) oder haiku. Werte: `sonnet`, `haiku`. |
| `fresh` | `boolean` | nein | Immer neu schreiben (keinen vorhandenen Entwurf wiederverwenden). |
| `save` | `string` | nein | none (Standard), draft (Vorschlag speichern) oder approval (in die Freigaben). Werte: `none`, `draft`, `approval`. |

```bash
curl -X POST https://persolz.com/api/v1/inbox/messages/42/ai-draft \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "tone": "herzlich",
    "instruction": "Biete Muster per Post an.",
    "save": "approval"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.subject` | `string` | Betreff. |
| `data.html` | `string` | HTML-Inhalt. |
| `data.text` | `string` | Text. |
| `data.confidence` | `integer` | Selbsteinschätzung der KI (0–100). |
| `data.risk_flags` | `array` | Risiken: legal, money, medical, threat, prompt_injection, personal_data, unclear. |
| `data.risk_flag_labels` | `array` | Risiken in Worten. |
| `data.needs_human` | `boolean` | Die KI empfiehlt, die Antwort selbst zu prüfen. |
| `data.reason` | `string` | Begründung der KI. |
| `data.language` | `string` | Sprache der Antwort. |
| `data.form_of_address` | `string` | Anrede der Antwort: du oder sie. |
| `data.slots` | `array` | Vorgeschlagene freie Termine (mit include_slots). |
| `data.model` | `string` | Verwendetes KI-Modell. |
| `data.cost_eur` | `number` | Kosten dieser KI-Anfrage in Euro (wird vom KI-Guthaben abgezogen). |
| `data.cached` | `boolean` | Ein vorhandener KI-Entwurf wurde ohne neue KI-Anfrage wiederverwendet. |
| `data.draft_id` | `integer` | Gespeicherter Entwurf (save=draft\|approval oder wiederverwendet) oder null. |

```json
{
  "data": {
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>herzlichen Glückwunsch! …</p>",
    "text": "Hallo Anna,\n\nherzlichen Glückwunsch! …",
    "confidence": 88,
    "risk_flags": [],
    "risk_flag_labels": [],
    "needs_human": false,
    "reason": "Preis und Lieferzeit stehen im Wissen für die KI.",
    "language": "de",
    "form_of_address": "du",
    "slots": [],
    "model": "claude-sonnet-5-5",
    "cost_eur": 0.0041,
    "cached": false,
    "draft_id": 5502
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="inbox-createdraft"></a>
#### Mail schreiben (Entwurf, senden oder planen)

`POST /api/v1/inbox/drafts`

Scope: `send` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Legt eine ausgehende Mail an und sendet sie sofort (Standard), zum Zeitpunkt send_at oder gar nicht (send=false → Entwurf). kind: new (neue Mail), reply / reply_all / forward (mit message_id – Empfänger, Betreff, Zitat und bei forward die Originalanhänge werden vorbelegt). Absender ist das Postfach (mailbox_id; bei message_id dessen Postfach).

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `mailbox_id` | `integer` | nein | Absender-Postfach (Pflicht ohne message_id). |
| `kind` | `string` | nein | new (Standard), reply, reply_all oder forward. Werte: `new`, `reply`, `reply_all`, `forward`. |
| `message_id` | `integer` | nein | Bezugsmail für reply, reply_all und forward. |
| `subject` | `string` | nein | Betreff (bei new Pflicht zum Senden). |
| `to` | `string|array` | nein | Empfänger: E-Mail, kommagetrennte E-Mails („Name &lt;mail@…&gt;“ erlaubt) oder Liste aus E-Mails bzw. Objekten {email, name}. |
| `cc` | `string|array` | nein | Kopie (wie to). |
| `bcc` | `string|array` | nein | Blindkopie (wie to). |
| `html` | `string` | nein | Inhalt als HTML (wird bereinigt; Skripte, Formulare & Co. werden entfernt). Signatur des Postfachs wird automatisch ergänzt. |
| `text` | `string` | nein | Alternativ: reiner Text (Links werden verlinkt). |
| `attachment_ids` | `array<integer>` | nein | Anhänge aus der Anhang-Cloud (GET /attachments). |
| `send_at` | `string` | nein | Später senden (ISO 8601, mind. 1 Minute, max. 1 Jahr in der Zukunft). Leer = sofort. |
| `mail_attachment_ids` | `array<integer>` | nein | forward: Anhänge der Originalmail (Standard: alle). |
| `quote` | `boolean` | nein | reply/forward: Original zitieren (Standard true). |
| `send` | `boolean` | nein | false = nur als Entwurf speichern (Standard true). |

```bash
curl -X POST https://persolz.com/api/v1/inbox/drafts \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "mailbox_id": 2,
    "to": ["anna@beispiel.de"],
    "subject": "Dein Angebot für Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>anbei Dein Angebot …</p>",
    "attachment_ids": [9],
    "send_at": "2026-10-10T09:00:00+02:00"
  }'
```

**Antwort 201** – Angelegt (gesendet, geplant oder Entwurf) · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "new",
    "kind_label": "Neue Mail",
    "status": "scheduled",
    "status_label": "Geplant",
    "source": "user",
    "reply_to_message_id": null,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Dein Angebot für Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [9],
    "mail_attachment_ids": [],
    "send_at": "2026-10-10T09:00:00+02:00",
    "sent_at": null,
    "sent_message_id": null,
    "error": null,
    "ai": null,
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="inbox-draft"></a>
#### Entwurf / ausgehende Mail abrufen

`GET /api/v1/inbox/drafts/{id}`

Scope: `read` · Tarif-Funktion: `inbox`

Status einer ausgehenden Mail: draft, pending (KI-Entwurf wartet auf Freigabe), scheduled (geplant oder im Rückgängig-Fenster), sending, sent, failed (error), rejected, cancelled.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/inbox/drafts/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "sent",
    "status_label": "Gesendet",
    "source": "user",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": "2026-10-09T14:21:00+02:00",
    "sent_at": "2026-10-09T14:21:04+02:00",
    "sent_message_id": "<6f1c0d9a2b7e4c1f@kartenherz.com>",
    "error": null,
    "ai": null,
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="inbox-senddraft"></a>
#### Entwurf senden

`POST /api/v1/inbox/drafts/{id}/send`

Scope: `send` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Sendet einen Entwurf (auch einen fehlgeschlagenen oder gestoppten) sofort oder zum Zeitpunkt send_at. KI-Entwürfe gibst Du mit POST /inbox/approvals/{id}/approve frei.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `send_at` | `string` | nein | Später senden (ISO 8601). Leer = sofort. |

```bash
curl -X POST https://persolz.com/api/v1/inbox/drafts/42/send \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Antwort 200** – OK · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "sent",
    "status_label": "Gesendet",
    "source": "user",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": "2026-10-09T14:21:00+02:00",
    "sent_at": "2026-10-09T14:21:04+02:00",
    "sent_message_id": "<6f1c0d9a2b7e4c1f@kartenherz.com>",
    "error": null,
    "ai": null,
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="inbox-canceldraft"></a>
#### Geplanten Versand stoppen

`POST /api/v1/inbox/drafts/{id}/cancel`

Scope: `send` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Stoppt eine geplante Mail (auch eine Autopilot-Antwort in der Wartezeit). Eigene Mails werden wieder Entwürfe, KI-Antworten landen wieder in den Freigaben. 409, wenn die Mail schon unterwegs ist.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/inbox/drafts/42/cancel \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)"
```

**Antwort 200** – OK · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "draft",
    "status_label": "Entwurf",
    "source": "user",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": null,
    "sent_at": null,
    "sent_message_id": null,
    "error": null,
    "ai": null,
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 429 `rate_limited`.

<a id="inbox-approvals"></a>
#### Freigaben auflisten

`GET /api/v1/inbox/approvals`

Scope: `read` · Tarif-Funktion: `inbox` · paginiert

KI-Entwürfe mit Auszug der Originalmail und den Gründen, warum der Autopilot sie nicht selbst gesendet hat (reasons). status: pending (Standard – wartet auf Freigabe), scheduled (geplant, z. B. Autopilot in der Wartezeit), sent (von der KI geschrieben und gesendet), rejected, draft (Vorschläge „Antwort bereit“), all.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | pending \| scheduled \| sent \| rejected \| draft \| all Werte: `pending`, `scheduled`, `sent`, `rejected`, `draft`, `all`. |
| `mailbox_id` | `integer` | nein | Nur dieses Postfach. |
| `q` | `string` | nein | Suche in Betreff und Absender. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/inbox/approvals?status=pending" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data[].reasons` | `array` | Gründe, warum der Autopilot nicht selbst gesendet hat. |
| `data[].message` | `object` | Die Originalmail (Auszug). |
| `data[].message.id` | `integer` | Eindeutige ID. |
| `data[].message.subject` | `string` | Betreff. |
| `data[].message.from` | `object` | Absender. |
| `data[].message.from.email` | `string` | E-Mail-Adresse des Absenders. |
| `data[].message.from.name` | `string` | Name des Absenders (kann leer sein). |
| `data[].message.received_at` | `string (Datum/Zeit)` | Eingang (ISO 8601). |
| `data[].message.summary` | `string` | Zusammenfassung in einem Satz (KI) oder null. |
| `data[].message.category` | `string` | KI-Kategorie: lead, customer, appointment, invoice, order, campaign_reply, partner, personal, newsletter, notification, unsubscribe, complaint, spam, other – oder null (noch nicht eingeordnet). |
| `data[].message.category_label` | `string` | Kategorie in Worten. |
| `data[].message.priority` | `string` | Priorität laut KI: high, normal oder low – oder null. |
| `data[].message.excerpt` | `string` | Auszug aus dem Text der Originalmail. |

```json
{
  "data": [
    {
      "id": 5502,
      "mailbox_id": 2,
      "kind": "reply",
      "kind_label": "Antwort",
      "status": "pending",
      "status_label": "Wartet auf Freigabe",
      "source": "ai",
      "reply_to_message_id": 9120,
      "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
      "to": [
        {
          "email": "anna@beispiel.de",
          "name": "Anna Schmidt"
        }
      ],
      "cc": [],
      "bcc": [],
      "subject": "Re: Frage zu Hochzeitskarten",
      "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
      "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
      "attachment_ids": [],
      "mail_attachment_ids": [],
      "send_at": null,
      "sent_at": null,
      "sent_message_id": null,
      "error": null,
      "ai": {
        "model": "claude-sonnet-5-5",
        "confidence": 88,
        "reason": "Preis und Lieferzeit stehen im Wissen für die KI.",
        "flags": [],
        "flag_labels": [],
        "rating": null,
        "edited": false
      },
      "created_at": "2026-10-09T14:06:10+02:00",
      "updated_at": "2026-10-09T14:06:10+02:00",
      "reasons": [],
      "message": {
        "id": 9120,
        "subject": "Frage zu Hochzeitskarten",
        "from": {
          "email": "anna@beispiel.de",
          "name": "Anna Schmidt"
        },
        "received_at": "2026-10-09T14:05:00+02:00",
        "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
        "category": "lead",
        "category_label": "Leads & Anfragen",
        "priority": "high",
        "excerpt": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung. Was kostet das und wie lange dauert die Lieferung?"
      }
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="inbox-approve"></a>
#### KI-Entwurf freigeben und senden

`POST /api/v1/inbox/approvals/{id}/approve`

Scope: `send` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Gibt einen KI-Entwurf frei und sendet ihn sofort – optional mit Änderungen (subject, html/text, to, cc, bcc, attachment_ids). Deine Änderungen fließen als Erkenntnisse in künftige KI-Entwürfe ein.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `subject` | `string` | nein | Geänderter Betreff. |
| `html` | `string` | nein | Geänderter Text als HTML. |
| `text` | `string` | nein | Alternativ: geänderter reiner Text. |
| `to` | `string|array` | nein | Geänderte Empfänger. |
| `cc` | `string|array` | nein | Kopie. |
| `bcc` | `string|array` | nein | Blindkopie. |
| `attachment_ids` | `array<integer>` | nein | Anhänge aus der Anhang-Cloud. |

```bash
curl -X POST https://persolz.com/api/v1/inbox/approvals/42/approve \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<p>Hallo Anna,</p><p>herzlichen Glückwunsch! Ich schicke Dir gern Muster per Post …</p>"
  }'
```

**Antwort 200** – OK · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.reasons` | `array` | Gründe, warum der Autopilot nicht selbst gesendet hat. |
| `data.message` | `object` | Die Originalmail (Auszug). |
| `data.message.id` | `integer` | Eindeutige ID. |
| `data.message.subject` | `string` | Betreff. |
| `data.message.from` | `object` | Absender. |
| `data.message.from.email` | `string` | E-Mail-Adresse des Absenders. |
| `data.message.from.name` | `string` | Name des Absenders (kann leer sein). |
| `data.message.received_at` | `string (Datum/Zeit)` | Eingang (ISO 8601). |
| `data.message.summary` | `string` | Zusammenfassung in einem Satz (KI) oder null. |
| `data.message.category` | `string` | KI-Kategorie: lead, customer, appointment, invoice, order, campaign_reply, partner, personal, newsletter, notification, unsubscribe, complaint, spam, other – oder null (noch nicht eingeordnet). |
| `data.message.category_label` | `string` | Kategorie in Worten. |
| `data.message.priority` | `string` | Priorität laut KI: high, normal oder low – oder null. |
| `data.message.excerpt` | `string` | Auszug aus dem Text der Originalmail. |

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "sent",
    "status_label": "Gesendet",
    "source": "ai",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": "2026-10-09T14:21:00+02:00",
    "sent_at": "2026-10-09T14:21:04+02:00",
    "sent_message_id": null,
    "error": null,
    "ai": {
      "model": "claude-sonnet-5-5",
      "confidence": 88,
      "reason": "Preis und Lieferzeit stehen im Wissen für die KI.",
      "flags": [],
      "flag_labels": [],
      "rating": null,
      "edited": true
    },
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00",
    "reasons": [],
    "message": {
      "id": 9120,
      "subject": "Frage zu Hochzeitskarten",
      "from": {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      },
      "received_at": "2026-10-09T14:05:00+02:00",
      "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
      "category": "lead",
      "category_label": "Leads & Anfragen",
      "priority": "high",
      "excerpt": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung. Was kostet das und wie lange dauert die Lieferung?"
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="inbox-reject"></a>
#### KI-Entwurf ablehnen

`POST /api/v1/inbox/approvals/{id}/reject`

Scope: `write` · Tarif-Funktion: `inbox` · unterstützt `Idempotency-Key`

Lehnt einen KI-Entwurf ab. Typische Gründe („zu lang“, „falscher Ton“, „stimmt nicht“) werden zu Erkenntnissen für künftige Entwürfe.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `reason` | `string` | nein | Grund (optional, max. 250 Zeichen). |

```bash
curl -X POST https://persolz.com/api/v1/inbox/approvals/42/reject \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Zu lang"
  }'
```

**Antwort 200** – OK · [Entwurfs-Objekt (Posteingang)](#obj-inbox-draft)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.reasons` | `array` | Gründe, warum der Autopilot nicht selbst gesendet hat. |
| `data.message` | `object` | Die Originalmail (Auszug). |
| `data.message.id` | `integer` | Eindeutige ID. |
| `data.message.subject` | `string` | Betreff. |
| `data.message.from` | `object` | Absender. |
| `data.message.from.email` | `string` | E-Mail-Adresse des Absenders. |
| `data.message.from.name` | `string` | Name des Absenders (kann leer sein). |
| `data.message.received_at` | `string (Datum/Zeit)` | Eingang (ISO 8601). |
| `data.message.summary` | `string` | Zusammenfassung in einem Satz (KI) oder null. |
| `data.message.category` | `string` | KI-Kategorie: lead, customer, appointment, invoice, order, campaign_reply, partner, personal, newsletter, notification, unsubscribe, complaint, spam, other – oder null (noch nicht eingeordnet). |
| `data.message.category_label` | `string` | Kategorie in Worten. |
| `data.message.priority` | `string` | Priorität laut KI: high, normal oder low – oder null. |
| `data.message.excerpt` | `string` | Auszug aus dem Text der Originalmail. |

```json
{
  "data": {
    "id": 5502,
    "mailbox_id": 2,
    "kind": "reply",
    "kind_label": "Antwort",
    "status": "rejected",
    "status_label": "Abgelehnt",
    "source": "ai",
    "reply_to_message_id": 9120,
    "thread_key": "3f1c9a7e5b2d4c8f9e0a1d2b3c4d5e6f70819a2b",
    "to": [
      {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      }
    ],
    "cc": [],
    "bcc": [],
    "subject": "Re: Frage zu Hochzeitskarten",
    "html": "<p>Hallo Anna,</p><p>wie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …</p>",
    "text": "Hallo Anna,\n\nwie schön, herzlichen Glückwunsch! 80 Karten mit Goldprägung kosten 236 € …",
    "attachment_ids": [],
    "mail_attachment_ids": [],
    "send_at": null,
    "sent_at": null,
    "sent_message_id": null,
    "error": null,
    "ai": {
      "model": "claude-sonnet-5-5",
      "confidence": 88,
      "reason": "Preis und Lieferzeit stehen im Wissen für die KI.",
      "flags": [],
      "flag_labels": [],
      "rating": null,
      "edited": false
    },
    "created_at": "2026-10-09T14:06:10+02:00",
    "updated_at": "2026-10-09T14:06:10+02:00",
    "reasons": [],
    "message": {
      "id": 9120,
      "subject": "Frage zu Hochzeitskarten",
      "from": {
        "email": "anna@beispiel.de",
        "name": "Anna Schmidt"
      },
      "received_at": "2026-10-09T14:05:00+02:00",
      "summary": "Anna möchte 80 Einladungskarten für ihre Hochzeit im Mai und fragt nach Preis und Lieferzeit.",
      "category": "lead",
      "category_label": "Leads & Anfragen",
      "priority": "high",
      "excerpt": "Hallo, wir heiraten im Mai und suchen 80 Einladungskarten mit Goldprägung. Was kostet das und wie lange dauert die Lieferung?"
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_error`, 429 `rate_limited`.

<a id="inbox-stats"></a>
#### Posteingang-Kennzahlen

`GET /api/v1/inbox/stats`

Scope: `read` · Tarif-Funktion: `inbox`

Zähler wie in der App (Ansichten, Kategorien, Freigaben; badge = Zahl am Menüpunkt: ungelesen im Fokus + Freigaben), Status der Postfächer, eingegangene, gesendete und beantwortete Mails im Zeitraum sowie die KI-Statistik: Entwürfe, Freigaben, Bearbeitungs- und Freigabequote, Autopilot-Antworten, geschätzte gesparte Zeit und Entwürfe pro Tag.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `days` | `integer` | nein | Zeitraum in Tagen (1–365, Standard 30). |

```bash
curl https://persolz.com/api/v1/inbox/stats \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.period` | `object` | Zeitraum der Kennzahlen. |
| `data.period.days` | `integer` | Tage. |
| `data.period.from` | `string (Datum)` | Erster Tag. |
| `data.period.to` | `string (Datum)` | Letzter Tag (heute). |
| `data.mailboxes` | `object` | Postfächer nach Status. |
| `data.mailboxes.total` | `integer` | Postfächer gesamt. |
| `data.mailboxes.ok` | `integer` | Ohne Fehler. |
| `data.mailboxes.error` | `integer` | Mit Fehler beim Abruf. |
| `data.mailboxes.paused` | `integer` | Pausiert. |
| `data.mailboxes.last_sync_at` | `string (Datum/Zeit)` | Letzter Abruf eines Postfachs oder null. |
| `data.views` | `object` | Zähler der Ansichten wie in der App (focus_unread = ungelesen im Fokus). |
| `data.views.focus` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.focus_unread` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.all` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.unread` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.flagged` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.waiting` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.snoozed` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.approvals` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.scheduled` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.failed` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.drafts` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.sent` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.archive` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.trash` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.views.spam` | `integer` | Anzahl Mails in dieser Ansicht. |
| `data.categories` | `object` | Mails im Posteingang je KI-Kategorie. |
| `data.categories.lead` | `object` | Kategorie. |
| `data.categories.lead.label` | `string` | Kategorie in Worten. |
| `data.categories.lead.total` | `integer` | Mails gesamt. |
| `data.categories.lead.unread` | `integer` | Davon ungelesen. |
| `data.categories.campaign_reply` | `object` | Kategorie. |
| `data.categories.campaign_reply.label` | `string` | Kategorie in Worten. |
| `data.categories.campaign_reply.total` | `integer` | Mails gesamt. |
| `data.categories.campaign_reply.unread` | `integer` | Davon ungelesen. |
| `data.approvals` | `integer` | KI-Entwürfe, die auf Deine Freigabe warten. |
| `data.badge` | `integer` | Zahl am Menüpunkt „Posteingang“ (ungelesen im Fokus + Freigaben). |
| `data.mails` | `object` | Mails im Zeitraum. |
| `data.mails.received` | `integer` | Eingegangen. |
| `data.mails.sent` | `integer` | Aus Persolz gesendet. |
| `data.mails.replied` | `integer` | Beantwortet. |
| `data.ai` | `object` | KI im Posteingang. |
| `data.ai.drafts` | `integer` | KI-Entwürfe im Zeitraum. |
| `data.ai.approved` | `integer` | Von Dir freigegeben. |
| `data.ai.edited` | `integer` | Davon vor dem Senden geändert. |
| `data.ai.rejected` | `integer` | Abgelehnt. |
| `data.ai.auto_sent` | `integer` | Vom Autopiloten gesendet. |
| `data.ai.auto_planned` | `integer` | Vom Autopiloten geplant (Wartezeit läuft). |
| `data.ai.pending` | `integer` | Warten aktuell auf Freigabe. |
| `data.ai.thumbs_up` | `integer` | Mit „gut“ bewertet. |
| `data.ai.thumbs_down` | `integer` | Mit „schlecht“ bewertet. |
| `data.ai.approval_rate` | `number` | Freigabequote (freigegeben / entschieden) oder null. |
| `data.ai.edit_rate` | `number` | Anteil der vor dem Senden geänderten Freigaben oder null. |
| `data.ai.avg_confidence` | `integer` | Durchschnittliche Selbsteinschätzung der KI oder null. |
| `data.ai.triaged` | `integer` | Von der KI eingeordnete Mails. |
| `data.ai.minutes_saved` | `integer` | Geschätzt gesparte Minuten. |
| `data.ai.hours_saved` | `number` | Geschätzt gesparte Stunden. |
| `data.ai.hours_saved_label` | `string` | Gesparte Zeit in Worten. |
| `data.ai.per_day` | `array<object>` | KI-Entwürfe pro Tag. |
| `data.ai.per_day[].date` | `string (Datum)` | Tag. |
| `data.ai.per_day[].drafts` | `integer` | KI-Entwürfe. |
| `data.ai.per_day[].auto` | `integer` | Davon Autopilot. |

```json
{
  "data": {
    "period": {
      "days": 30,
      "from": "2026-09-10",
      "to": "2026-10-09"
    },
    "mailboxes": {
      "total": 2,
      "ok": 2,
      "error": 0,
      "paused": 0,
      "last_sync_at": "2026-10-09T14:05:00+02:00"
    },
    "views": {
      "focus": 7,
      "focus_unread": 3,
      "all": 214,
      "unread": 5,
      "flagged": 4,
      "waiting": 2,
      "snoozed": 1,
      "approvals": 2,
      "scheduled": 1,
      "failed": 0,
      "drafts": 1,
      "sent": 96,
      "archive": 812,
      "trash": 12,
      "spam": 0
    },
    "categories": {
      "lead": {
        "label": "Leads & Anfragen",
        "total": 18,
        "unread": 2
      },
      "campaign_reply": {
        "label": "Leser-Antworten",
        "total": 9,
        "unread": 1
      }
    },
    "approvals": 2,
    "badge": 5,
    "mails": {
      "received": 320,
      "sent": 104,
      "replied": 87
    },
    "ai": {
      "drafts": 41,
      "approved": 33,
      "edited": 9,
      "rejected": 3,
      "auto_sent": 12,
      "auto_planned": 1,
      "pending": 2,
      "thumbs_up": 6,
      "thumbs_down": 1,
      "approval_rate": 0.9167,
      "edit_rate": 0.2727,
      "avg_confidence": 84,
      "triaged": 298,
      "minutes_saved": 462,
      "hours_saved": 7.7,
      "hours_saved_label": "ca. 7,7 Stunden",
      "per_day": [
        {
          "date": "2026-10-08",
          "drafts": 3,
          "auto": 1
        },
        {
          "date": "2026-10-09",
          "drafts": 2,
          "auto": 0
        }
      ]
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-ai"></a>
### KI

KI-Texte erzeugen und den KI-Verbrauch abfragen.

KI-Anfragen verbrauchen Dein monatliches KI-Guthaben. Den Stand zeigt `GET /ai/usage`.

<a id="ai-generate"></a>
#### KI-Text erzeugen

`POST /api/v1/ai/generate`

Scope: `ai` · Tarif-Funktion: `ai` · unterstützt `Idempotency-Key`

Schreibt oder überarbeitet Texte mit der Persolz-KI (wie im Editor): improve, shorten, expand, subject_ideas (8 Betreffzeilen), preheader (3 Vorschläge), urgency, story, emotional, fix, translate_en, custom. Statt action kannst Du einfach „prompt“ senden (= custom). Verbraucht KI-Guthaben; 30 Anfragen pro Minute.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `action` | `string` | nein | Aktion (siehe Beschreibung). Werte: `improve`, `shorten`, `expand`, `subject_ideas`, `preheader`, `urgency`, `story`, `emotional`, `fix`, `translate_en`, `custom`. |
| `prompt` | `string` | nein | Freie Anweisung (setzt action=custom). |
| `text` | `string` | nein | Ausgangstext. |
| `html` | `string` | nein | Alternativ: HTML-Fragment (Struktur bleibt erhalten). |
| `instruction` | `string` | nein | Zusätzlicher Wunsch. |
| `subject` | `string` | nein | Betreff als Kontext. |
| `context` | `string` | nein | Hintergrund (max. 4000 Zeichen). |
| `scope` | `string` | nein | seinfeld \| soap \| newsletter \| email (nutzt passende A/B-Erkenntnisse). |
| `model` | `string` | nein | haiku (Standard, schnell) \| sonnet (beste Qualität) |

```bash
curl -X POST https://persolz.com/api/v1/ai/generate \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Idempotency-Key: $(openssl rand -hex 16)" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "subject_ideas",
    "text": "Unser Herbst-Workshop findet am 20.10. statt – nur 12 Plätze.",
    "scope": "newsletter"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.action` | `string` | Ausgeführte Aktion. |
| `data.text` | `string` | Erzeugter Text. |
| `data.html` | `string · null` | Erzeugtes HTML (wenn HTML gesendet wurde) oder null. |
| `data.data` | `object` | Strukturierte Zusatzdaten. |
| `data.data.ideas` | `array<string>` | Vorschläge (z. B. 8 Betreffzeilen bei subject_ideas). |
| `data.cost_eur` | `number` | Kosten dieser KI-Anfrage in Euro (wird vom KI-Guthaben abgezogen). |
| `data.remaining_eur` | `number` | Verbleibendes KI-Guthaben in Euro. |
| `data.model` | `string` | Verwendetes KI-Modell. |
| `data.provider` | `string` | KI-Anbieter: anthropic oder openai. |
| `data.fallback_used` | `boolean` | Der Ersatz-Anbieter hat geantwortet, weil der Haupt-Anbieter nicht erreichbar war. |

```json
{
  "data": {
    "action": "subject_ideas",
    "text": "Nur 12 Plätze: Dein Herbst-Workshop\n…",
    "html": null,
    "data": {
      "ideas": ["Nur 12 Plätze: Dein Herbst-Workshop", "…"]
    },
    "cost_eur": 0.0012,
    "remaining_eur": 7.68,
    "model": "claude-haiku-5-5",
    "provider": "anthropic",
    "fallback_used": false
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`, 503 `service_unavailable`.

<a id="ai-usage"></a>
#### KI-Verbrauch

`GET /api/v1/ai/usage`

Scope: `read`

Guthaben, Verbrauch und Anfragen im laufenden Monat.

```bash
curl https://persolz.com/api/v1/ai/usage \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.credit` | `number` | Monatliches KI-Guthaben in Euro. |
| `data.used` | `number` | Im laufenden Monat verbraucht (Euro). |
| `data.remaining` | `number` | Verbleibendes KI-Guthaben in Euro. |
| `data.requests` | `integer` | KI-Anfragen im laufenden Monat. |
| `data.limit` | `integer` | Max. KI-Anfragen pro Monat (0 = unbegrenzt). |
| `data.period` | `string` | Abrechnungsmonat (YYYY-MM). |
| `data.bonus` | `number` | Zusätzliches Bonus-Guthaben in Euro. |
| `data.percent` | `number` | Verbrauch in Prozent des Guthabens. |
| `data.allowed` | `boolean` | KI ist gerade nutzbar (Tarif und Guthaben). |
| `data.configured` | `boolean` | Die KI ist auf dem Server eingerichtet. |

```json
{
  "data": {
    "credit": 10.0,
    "used": 2.31,
    "remaining": 7.69,
    "requests": 12,
    "limit": 0,
    "period": "2026-10",
    "bonus": 0.0,
    "percent": 23.1,
    "allowed": true,
    "configured": true
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-webhooks"></a>
### Webhooks

Webhooks verwalten, testen und Zustellungen einsehen.

Wie Du Webhooks empfängst und die Signatur prüfst, steht im Abschnitt [Webhooks](#webhooks).

<a id="obj-webhook"></a>
#### Das Webhook-Objekt

Ein Webhook ohne Secret – das Secret gibt es nur beim Anlegen und beim Erneuern.

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `url` | `string` | Deine Empfänger-URL (https). |
| `description` | `string` | Beschreibung. |
| `events` | `array<string>` | Abonnierte Ereignisse (["*"] = alle). |
| `is_active` | `boolean` | Webhook ist aktiv. |
| `failure_count` | `integer` | Fehlschläge in Folge – nach 20 wird der Webhook pausiert. |
| `last_status` | `integer` | Letzter HTTP-Status Deines Endpunkts oder null. |
| `last_error` | `string · null` | Letzte Fehlermeldung oder null. |
| `last_called_at` | `string (Datum/Zeit)` | Letzter Aufruf oder null. |
| `disabled_at` | `string · null` | Zeitpunkt der automatischen Pausierung oder null. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `updated_at` | `string · null` | Zeitpunkt der letzten Änderung (ISO 8601) oder null. |
| `secret_hint` | `string` | Anfang des Secrets zum Wiedererkennen. |

```json
{
  "id": 4,
  "url": "https://kartenherz.com/hooks/persolz",
  "description": "Shop-Synchronisation",
  "events": ["contact.created", "contact.unsubscribed"],
  "is_active": true,
  "failure_count": 0,
  "last_status": 200,
  "last_error": null,
  "last_called_at": "2026-10-09T14:05:01+02:00",
  "disabled_at": null,
  "created_at": "2026-10-01T09:00:00+02:00",
  "updated_at": null,
  "secret_hint": "whsec_3f9a…"
}
```

<a id="obj-delivery"></a>
#### Das Zustellungs-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `webhook_id` | `integer` | ID des Webhooks. |
| `event` | `string` | Name des Ereignisses. |
| `event_id` | `string` | ID des Ereignisses (evt_…) – bei Wiederholungen gleich, ideal zum Entdoppeln. |
| `status` | `string` | pending (offen), delivered (zugestellt) oder failed (endgültig fehlgeschlagen). |
| `attempts` | `integer` | Bisherige Zustellversuche. |
| `response_code` | `integer` | HTTP-Status Deines Endpunkts oder null. |
| `response_body` | `string` | Anfang der Antwort Deines Endpunkts. |
| `error` | `string · null` | Fehler beim letzten Versuch oder null. |
| `duration_ms` | `integer` | Dauer in Millisekunden. |
| `next_attempt_at` | `string · null` | Nächster Zustellversuch oder null. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |
| `delivered_at` | `string (Datum/Zeit)` | Zeitpunkt der erfolgreichen Zustellung oder null. |
| `payload` | `object` | Gesendeter JSON-Body (Umschlag mit id, event, created_at, data). |
| `payload.id` | `string` | ID des Ereignisses. |
| `payload.event` | `string` | Name des Ereignisses. |
| `payload.created_at` | `string (Datum/Zeit)` | Zeitpunkt des Ereignisses. |
| `payload.data` | `object` | Daten des Ereignisses. |
| `payload.data.contact` | `Kontakt-Objekt` | Der Kontakt. Siehe [Kontakt-Objekt](#obj-contact). |

```json
{
  "id": 9001,
  "webhook_id": 4,
  "event": "contact.created",
  "event_id": "evt_3f9a1c2b4d5e6f708192a3b4",
  "status": "delivered",
  "attempts": 1,
  "response_code": 200,
  "response_body": "ok",
  "error": null,
  "duration_ms": 182,
  "next_attempt_at": null,
  "created_at": "2026-10-09T14:05:00+02:00",
  "delivered_at": "2026-10-09T14:05:01+02:00",
  "payload": {
    "id": "evt_3f9a1c2b4d5e6f708192a3b4",
    "event": "contact.created",
    "created_at": "2026-10-09T14:05:00+02:00",
    "data": {
      "contact": {
        "id": 42,
        "email": "anna@beispiel.de",
        "first_name": "Anna",
        "last_name": "Schmidt",
        "address": "Lindenweg 3",
        "zip": "10115",
        "city": "Berlin",
        "phone": "+49 30 1234567",
        "note": "Kam über den Kartenherz-Funnel",
        "tags": ["funnel", "kartenherz"],
        "status": "subscribed",
        "source": "api",
        "custom_fields": {
          "kundennummer": "K-1001"
        },
        "doi_confirmed_at": null,
        "created_at": "2026-10-09T14:05:00+02:00",
        "updated_at": "2026-10-09T14:05:00+02:00",
        "lists": [
          {
            "id": 3,
            "name": "Kartenherz Funnel",
            "color": "#5B3DF5"
          }
        ]
      }
    }
  }
}
```

<a id="webhooks-list"></a>
#### Webhooks auflisten

`GET /api/v1/webhooks`

Scope: `read`

Alle Webhooks mit Zustellstatistik der letzten 7 Tage (ohne Secret).

```bash
curl https://persolz.com/api/v1/webhooks \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Webhook-Objekt](#obj-webhook)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data[].stats_7d` | `object` | Zustellungen der letzten 7 Tage. |
| `data[].stats_7d.total` | `integer` | Zustellungen gesamt. |
| `data[].stats_7d.delivered` | `integer` | Erfolgreich zugestellt. |
| `data[].stats_7d.failed` | `integer` | Endgültig fehlgeschlagen. |
| `data[].stats_7d.pending` | `integer` | Noch offen (wartet auf den nächsten Versuch). |

```json
{
  "data": [
    {
      "id": 4,
      "url": "https://kartenherz.com/hooks/persolz",
      "description": "Shop-Synchronisation",
      "events": ["contact.created", "contact.unsubscribed"],
      "is_active": true,
      "failure_count": 0,
      "last_status": 200,
      "last_error": null,
      "last_called_at": "2026-10-09T14:05:01+02:00",
      "disabled_at": null,
      "created_at": "2026-10-01T09:00:00+02:00",
      "updated_at": null,
      "secret_hint": "whsec_3f9a…",
      "stats_7d": {
        "total": 120,
        "delivered": 119,
        "failed": 1,
        "pending": 0
      }
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="webhooks-create"></a>
#### Webhook anlegen

`POST /api/v1/webhooks`

Scope: `write` · unterstützt `Idempotency-Key`

Legt einen Webhook an. Die Antwort enthält das Signatur-Secret (nur hier und auf der Entwickler-Seite sichtbar). Max. 20 Webhooks.

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `url` | `string` | ja | Öffentlich erreichbare http(s)-URL Deines Endpunkts (https empfohlen; keine internen IPs oder localhost). |
| `events` | `array<string>` | nein | Ereignisse oder ["*"] für alle: contact.created, contact.updated, contact.deleted, contact.unsubscribed, list.joined, email.opened, email.clicked, campaign.sent, soap.enrolled, soap.completed, appointment.created, appointment.updated, inbox.received, inbox.replied, inbox.approval_needed, inbox.auto_replied |
| `description` | `string` | nein | Beschreibung. |
| `is_active` | `boolean` | nein | Aktiv (Reaktivieren setzt den Fehlerzähler zurück). |

```bash
curl -X POST https://persolz.com/api/v1/webhooks \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://kartenherz.com/hooks/persolz",
    "events": ["contact.created", "contact.unsubscribed"],
    "description": "Shop-Synchronisation"
  }'
```

**Antwort 201** – Angelegt · [Webhook-Objekt](#obj-webhook)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.secret` | `string` | Signatur-Secret zum Prüfen der Webhooks – wird nur beim Anlegen bzw. Erneuern ausgegeben. |

```json
{
  "data": {
    "id": 4,
    "url": "https://kartenherz.com/hooks/persolz",
    "description": "Shop-Synchronisation",
    "events": ["contact.created", "contact.unsubscribed"],
    "is_active": true,
    "failure_count": 0,
    "last_status": 200,
    "last_error": null,
    "last_called_at": "2026-10-09T14:05:01+02:00",
    "disabled_at": null,
    "created_at": "2026-10-01T09:00:00+02:00",
    "updated_at": null,
    "secret_hint": "whsec_3f9a…",
    "secret": "whsec_3f9a1c2b4d5e6f708192a3b4c5d6e7f8091a2b3c"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 402 `limit_reached`, 403 `forbidden`, 422 `validation_error`, 429 `rate_limited`.

<a id="webhooks-get"></a>
#### Webhook abrufen

`GET /api/v1/webhooks/{id}`

Scope: `read`

Ein Webhook (ohne Secret).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl https://persolz.com/api/v1/webhooks/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · [Webhook-Objekt](#obj-webhook)

```json
{
  "data": {
    "id": 4,
    "url": "https://kartenherz.com/hooks/persolz",
    "description": "Shop-Synchronisation",
    "events": ["contact.created", "contact.unsubscribed"],
    "is_active": true,
    "failure_count": 0,
    "last_status": 200,
    "last_error": null,
    "last_called_at": "2026-10-09T14:05:01+02:00",
    "disabled_at": null,
    "created_at": "2026-10-01T09:00:00+02:00",
    "updated_at": null,
    "secret_hint": "whsec_3f9a…"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="webhooks-update"></a>
#### Webhook ändern

`PATCH /api/v1/webhooks/{id}`

Scope: `write`

Ändert URL, Ereignisse, Beschreibung oder Aktivierung. rotate_secret=true erzeugt ein neues Secret (in der Antwort enthalten).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `url` | `string` | ja | Öffentlich erreichbare http(s)-URL Deines Endpunkts (https empfohlen; keine internen IPs oder localhost). |
| `events` | `array<string>` | nein | Ereignisse oder ["*"] für alle: contact.created, contact.updated, contact.deleted, contact.unsubscribed, list.joined, email.opened, email.clicked, campaign.sent, soap.enrolled, soap.completed, appointment.created, appointment.updated, inbox.received, inbox.replied, inbox.approval_needed, inbox.auto_replied |
| `description` | `string` | nein | Beschreibung. |
| `is_active` | `boolean` | nein | Aktiv (Reaktivieren setzt den Fehlerzähler zurück). |
| `rotate_secret` | `boolean` | nein | Neues Signatur-Secret erzeugen. |

```bash
curl -X PATCH https://persolz.com/api/v1/webhooks/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["*"],
    "is_active": true
  }'
```

**Antwort 200** – OK · [Webhook-Objekt](#obj-webhook)

```json
{
  "data": {
    "id": 4,
    "url": "https://kartenherz.com/hooks/persolz",
    "description": "Shop-Synchronisation",
    "events": ["contact.created", "contact.unsubscribed"],
    "is_active": true,
    "failure_count": 0,
    "last_status": 200,
    "last_error": null,
    "last_called_at": "2026-10-09T14:05:01+02:00",
    "disabled_at": null,
    "created_at": "2026-10-01T09:00:00+02:00",
    "updated_at": null,
    "secret_hint": "whsec_3f9a…"
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="webhooks-delete"></a>
#### Webhook löschen

`DELETE /api/v1/webhooks/{id}`

Scope: `write`

Löscht den Webhook und seine Zustellungen.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X DELETE https://persolz.com/api/v1/webhooks/42 \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 204** – Gelöscht

```text
HTTP/1.1 204 No Content

(kein Inhalt – der Status allein bestätigt den Erfolg)
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="webhooks-test"></a>
#### Test-Ping senden

`POST /api/v1/webhooks/{id}/test`

Scope: `write` · unterstützt `Idempotency-Key`

Sendet sofort ein Ereignis „test.ping“ an den Webhook und liefert das Ergebnis der Zustellung.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

```bash
curl -X POST https://persolz.com/api/v1/webhooks/42/test \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.delivered` | `boolean` | true, wenn Dein Endpunkt mit 2xx geantwortet hat. |
| `data.status` | `integer` | HTTP-Status Deines Endpunkts oder null. |
| `data.error` | `string · null` | Fehlermeldung oder null. |
| `data.delivery` | `Zustellungs-Objekt` | Die Zustellung. Siehe [Zustellungs-Objekt](#obj-delivery). |

```json
{
  "data": {
    "delivered": true,
    "status": 200,
    "error": null,
    "delivery": {
      "event": "test.ping",
      "status": "delivered",
      "id": 9001,
      "webhook_id": 4,
      "event_id": "evt_3f9a1c2b4d5e6f708192a3b4",
      "attempts": 1,
      "response_code": 200,
      "response_body": "ok",
      "error": null,
      "duration_ms": 182,
      "next_attempt_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "delivered_at": "2026-10-09T14:05:01+02:00",
      "payload": {
        "id": "evt_3f9a1c2b4d5e6f708192a3b4",
        "event": "contact.created",
        "created_at": "2026-10-09T14:05:00+02:00",
        "data": {
          "contact": {
            "id": 42,
            "email": "anna@beispiel.de",
            "first_name": "Anna",
            "last_name": "Schmidt",
            "address": "Lindenweg 3",
            "zip": "10115",
            "city": "Berlin",
            "phone": "+49 30 1234567",
            "note": "Kam über den Kartenherz-Funnel",
            "tags": ["funnel", "kartenherz"],
            "status": "subscribed",
            "source": "api",
            "custom_fields": {
              "kundennummer": "K-1001"
            },
            "doi_confirmed_at": null,
            "created_at": "2026-10-09T14:05:00+02:00",
            "updated_at": "2026-10-09T14:05:00+02:00",
            "lists": [
              {
                "id": 3,
                "name": "Kartenherz Funnel",
                "color": "#5B3DF5"
              }
            ]
          }
        }
      }
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="webhooks-deliveries"></a>
#### Zustellungen

`GET /api/v1/webhooks/{id}/deliveries`

Scope: `read` · paginiert

Zustellprotokoll (neueste zuerst) mit Payload, Antwortcode, Antwort-Auszug und nächstem Versuch. Aufbewahrung 30 Tage.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | pending \| delivered \| failed Werte: `pending`, `delivered`, `failed`. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/webhooks/42/deliveries?status=failed" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Zustellungs-Objekt](#obj-delivery)

```json
{
  "data": [
    {
      "id": 9001,
      "webhook_id": 4,
      "event": "contact.created",
      "event_id": "evt_3f9a1c2b4d5e6f708192a3b4",
      "status": "delivered",
      "attempts": 1,
      "response_code": 200,
      "response_body": "ok",
      "error": null,
      "duration_ms": 182,
      "next_attempt_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "delivered_at": "2026-10-09T14:05:01+02:00",
      "payload": {
        "id": "evt_3f9a1c2b4d5e6f708192a3b4",
        "event": "contact.created",
        "created_at": "2026-10-09T14:05:00+02:00",
        "data": {
          "contact": {
            "id": 42,
            "email": "anna@beispiel.de",
            "first_name": "Anna",
            "last_name": "Schmidt",
            "address": "Lindenweg 3",
            "zip": "10115",
            "city": "Berlin",
            "phone": "+49 30 1234567",
            "note": "Kam über den Kartenherz-Funnel",
            "tags": ["funnel", "kartenherz"],
            "status": "subscribed",
            "source": "api",
            "custom_fields": {
              "kundennummer": "K-1001"
            },
            "doi_confirmed_at": null,
            "created_at": "2026-10-09T14:05:00+02:00",
            "updated_at": "2026-10-09T14:05:00+02:00",
            "lists": [
              {
                "id": 3,
                "name": "Kartenherz Funnel",
                "color": "#5B3DF5"
              }
            ]
          }
        }
      }
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="webhooks-resend"></a>
#### Zustellung erneut senden

`POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/resend`

Scope: `write` · unterstützt `Idempotency-Key`

Sendet eine Zustellung sofort noch einmal (gleiche Event-ID, neue Signatur).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `id` | `integer` | ja | ID der Ressource |
| `deliveryId` | `integer` | ja | ID der Zustellung |

```bash
curl -X POST https://persolz.com/api/v1/webhooks/42/deliveries/9001/resend \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.delivered` | `boolean` | true, wenn Dein Endpunkt mit 2xx geantwortet hat. |
| `data.status` | `integer` | HTTP-Status Deines Endpunkts oder null. |
| `data.error` | `string · null` | Fehlermeldung oder null. |
| `data.delivery` | `Zustellungs-Objekt` | Die Zustellung. Siehe [Zustellungs-Objekt](#obj-delivery). |

```json
{
  "data": {
    "delivered": true,
    "status": 200,
    "error": null,
    "delivery": {
      "id": 9001,
      "webhook_id": 4,
      "event": "contact.created",
      "event_id": "evt_3f9a1c2b4d5e6f708192a3b4",
      "status": "delivered",
      "attempts": 1,
      "response_code": 200,
      "response_body": "ok",
      "error": null,
      "duration_ms": 182,
      "next_attempt_at": null,
      "created_at": "2026-10-09T14:05:00+02:00",
      "delivered_at": "2026-10-09T14:05:01+02:00",
      "payload": {
        "id": "evt_3f9a1c2b4d5e6f708192a3b4",
        "event": "contact.created",
        "created_at": "2026-10-09T14:05:00+02:00",
        "data": {
          "contact": {
            "id": 42,
            "email": "anna@beispiel.de",
            "first_name": "Anna",
            "last_name": "Schmidt",
            "address": "Lindenweg 3",
            "zip": "10115",
            "city": "Berlin",
            "phone": "+49 30 1234567",
            "note": "Kam über den Kartenherz-Funnel",
            "tags": ["funnel", "kartenherz"],
            "status": "subscribed",
            "source": "api",
            "custom_fields": {
              "kundennummer": "K-1001"
            },
            "doi_confirmed_at": null,
            "created_at": "2026-10-09T14:05:00+02:00",
            "updated_at": "2026-10-09T14:05:00+02:00",
            "lists": [
              {
                "id": 3,
                "name": "Kartenherz Funnel",
                "color": "#5B3DF5"
              }
            ]
          }
        }
      }
    }
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 404 `not_found`, 429 `rate_limited`.

<a id="ref-logs"></a>
### Protokolle

Aktivitätsprotokoll und API-Protokoll Deines Kontos.

<a id="obj-log"></a>
#### Das Protokoll-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `level` | `string` | Stufe: debug, info, success, warning, error oder critical. |
| `channel` | `string` | Bereich, z. B. email, newsletter, soap, contacts, smtp, api. |
| `message` | `string` | Meldung. |
| `context` | `object` | Zusatzdaten zum Eintrag (Geheimnisse maskiert). |
| `context.campaign_id` | `integer` | Betroffene Kampagne. |
| `context.sent` | `integer` | Versendete Mails. |
| `ip` | `string · null` | IP-Adresse. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 1201,
  "level": "success",
  "channel": "newsletter",
  "message": "Newsletter versendet: „Herbst-Aktion“",
  "context": {
    "campaign_id": 812,
    "sent": 1248
  },
  "ip": null,
  "created_at": "2026-10-09T09:04:31+02:00"
}
```

<a id="obj-api-log"></a>
#### Das API-Protokoll-Objekt

| Feld | Typ | Beschreibung |
|---|---|---|
| `id` | `integer` | Eindeutige ID. |
| `method` | `string` | HTTP-Methode. |
| `endpoint` | `string` | Aufgerufener Pfad. |
| `route` | `string` | Erkannte Route (Muster, z. B. /api/v1/contacts/{id}) oder null. |
| `status_code` | `integer` | HTTP-Status der Antwort. |
| `duration_ms` | `integer` | Dauer in Millisekunden. |
| `ip` | `string` | IP-Adresse. |
| `origin` | `string` | Origin-Header (bei Aufrufen aus dem Browser) oder null. |
| `user_agent` | `string` | User-Agent der Anfrage. |
| `error` | `string · null` | Fehlermeldung bei Status ≥ 400, sonst null. |
| `request_id` | `string` | Request-ID – gib sie bei Support-Anfragen an. |
| `key` | `object` | Verwendeter API-Schlüssel. |
| `key.id` | `integer` | ID des Schlüssels. |
| `key.name` | `string` | Name des Schlüssels. |
| `key.prefix` | `string` | Anfang des Schlüssels. |
| `created_at` | `string (Datum/Zeit)` | Zeitpunkt der Anlage (ISO 8601). |

```json
{
  "id": 3301,
  "method": "POST",
  "endpoint": "/api/v1/contacts",
  "route": "/api/v1/contacts",
  "status_code": 201,
  "duration_ms": 48,
  "ip": "203.0.113.7",
  "origin": "https://kartenherz.com",
  "user_agent": "Mozilla/5.0",
  "error": null,
  "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
  "key": {
    "id": 5,
    "name": "Funnel kartenherz.com",
    "prefix": "pz_live_AbCdEf"
  },
  "created_at": "2026-10-09T14:05:00+02:00"
}
```

<a id="logs-list"></a>
#### Aktivitätsprotokoll

`GET /api/v1/logs`

Scope: `read` · paginiert

Das Protokoll Deines Kontos (Versand, Kontakte, Automationen, Fehler …), neueste zuerst. Geheimnisse sind maskiert.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `level` | `string` | nein | debug \| info \| success \| warning \| error \| critical \| problems (= ab warning) |
| `channel` | `string` | nein | Bereich, z. B. email, newsletter, soap, contacts, smtp, api |
| `q` | `string` | nein | Volltextsuche. |
| `from` | `string` | nein | Ab Datum YYYY-MM-DD. |
| `to` | `string` | nein | Bis Datum YYYY-MM-DD. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/logs?level=problems" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [Protokoll-Objekt](#obj-log)

```json
{
  "data": [
    {
      "id": 1201,
      "level": "success",
      "channel": "newsletter",
      "message": "Newsletter versendet: „Herbst-Aktion“",
      "context": {
        "campaign_id": 812,
        "sent": 1248
      },
      "ip": null,
      "created_at": "2026-10-09T09:04:31+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="logs-api"></a>
#### API-Protokoll

`GET /api/v1/api-logs`

Scope: `read` · paginiert

Deine API-Anfragen (aller Schlüssel) mit Status, Dauer, Herkunft und Fehlermeldung. Aufbewahrung 90 Tage.

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `status` | `string` | nein | success (2xx/3xx) \| error (≥ 400) oder ein exakter Code wie 422 |
| `method` | `string` | nein | GET \| POST \| PUT \| PATCH \| DELETE |
| `key_id` | `integer` | nein | Nur Anfragen dieses Schlüssels. |
| `q` | `string` | nein | Suche in Pfad, IP, Fehler. |
| `from` | `string` | nein | Ab Datum. |
| `to` | `string` | nein | Bis Datum. |
| `page` | `integer` | nein | Seite (ab 1). Standard: `1`. |
| `per_page` | `integer` | nein | Einträge pro Seite (1–100). Standard: `25`. |

```bash
curl "https://persolz.com/api/v1/api-logs?status=error" \
  -H "Authorization: Bearer pz_live_DEIN_API_SCHLUESSEL"
```

**Antwort 200** – OK · Liste von [API-Protokoll-Objekt](#obj-api-log)

```json
{
  "data": [
    {
      "id": 3301,
      "method": "POST",
      "endpoint": "/api/v1/contacts",
      "route": "/api/v1/contacts",
      "status_code": 201,
      "duration_ms": 48,
      "ip": "203.0.113.7",
      "origin": "https://kartenherz.com",
      "user_agent": "Mozilla/5.0",
      "error": null,
      "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
      "key": {
        "id": 5,
        "name": "Funnel kartenherz.com",
        "prefix": "pz_live_AbCdEf"
      },
      "created_at": "2026-10-09T14:05:00+02:00"
    }
  ],
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e",
    "pagination": {
      "page": 1,
      "per_page": 25,
      "total": 1,
      "pages": 1
    }
  }
}
```

Mögliche Fehler: 401 `unauthorized`, 402 `plan_required`, 403 `forbidden`, 429 `rate_limited`.

<a id="ref-public"></a>
### Öffentliche Anmeldung

Anmeldeformulare (z. B. aus Deinem Funnel) – ohne API-Schlüssel.

Diese Endpunkte brauchen **keinen API-Schlüssel** und sind für Formulare auf Deiner Website gedacht. Anleitung: [Funnel-Integration](#funnel-html).

<a id="public-subscribe"></a>
#### Öffentliche Anmeldung (ohne Schlüssel)

`POST /api/v1/subscribe/{token}`

Öffentlich (kein API-Schlüssel)

Anmeldung an eine Liste über deren öffentlichen Token – für Formulare, Landingpages und Funnels (CORS „*“). Akzeptiert JSON oder Formulardaten. Double-Opt-in der Liste wird beachtet (Status pending + Bestätigungsmail). Spamschutz: Das Honeypot-Feld „website“ muss leer bleiben; max. 10 Anmeldungen pro Minute je IP und 200 pro Tag je Liste. HTML-Formulare (Accept: text/html oder mit success_url) werden per 303 auf success_url weitergeleitet (bei Fehlern auf error_url, sonst eine kleine Danke-/Fehlerseite).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `token` | `string` | ja | Öffentlicher Token der Liste (32 Zeichen, siehe GET /lists). |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `email` | `string` | ja | E-Mail-Adresse. |
| `first_name` | `string` | nein | Vorname. |
| `last_name` | `string` | nein | Nachname. |
| `phone` | `string` | nein | Telefon. |
| `address` | `string` | nein | Adresse. |
| `zip` | `string` | nein | PLZ. |
| `city` | `string` | nein | Ort. |
| `note` | `string` | nein | Notiz (max. 1000 Zeichen). |
| `tags` | `array<string>` | nein | Tags (Array oder kommagetrennt, max. 10). |
| `website` | `string` | nein | Honeypot – muss leer bleiben. |
| `success_url` | `string` | nein | Weiterleitung nach Erfolg (http/https). Hat die Liste „Erlaubte Weiterleitungs-Domains“, werden andere Hosts ignoriert (→ Persolz-Danke-Seite). |
| `error_url` | `string` | nein | Weiterleitung bei Fehlern (?error=… wird angehängt) – nur bei gültigem Listen-Token und erlaubter Domain. |

```bash
curl -X POST https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c \
  -H "Content-Type: application/json" \
  -d '{
    "email": "anna@beispiel.de",
    "first_name": "Anna",
    "tags": ["funnel"],
    "website": ""
  }'
```

**Antwort 201** – Angemeldet (200 = bereits bekannt / aktualisiert)

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.state` | `string` | subscribed (angemeldet) oder pending (Bestätigung per E-Mail nötig). |
| `data.double_optin` | `boolean` | Die Liste verlangt eine Bestätigung per E-Mail. |
| `data.message` | `string` | Verständliche Meldung auf Deutsch. |
| `data.redirect_url` | `string · null` | Weiterleitungsziel der Liste nach der Anmeldung oder null. |

```json
{
  "data": {
    "state": "pending",
    "double_optin": true,
    "message": "Fast geschafft! Wir haben Dir eine E-Mail geschickt – bitte bestätige Deine Anmeldung über den Link darin.",
    "redirect_url": null
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 403 `forbidden`, 404 `not_found`, 422 `validation_error`, 429 `rate_limited`.

<a id="public-form"></a>
#### Einbettbares Anmeldeformular

`GET /api/v1/subscribe/{token}/form`

Öffentlich (kein API-Schlüssel)

Ein schlichtes, responsives HTML-Anmeldeformular für die Liste – zum Verlinken oder per &lt;iframe&gt; einbetten. Optionen per Query: fields (z. B. first_name,last_name), button, title, success_url, theme (light|dark), accent (Hex ohne #).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `token` | `string` | ja | Öffentlicher Token der Liste. |

**Query-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `fields` | `string` | nein | Zusätzliche Felder: first_name, last_name, phone, city … |
| `button` | `string` | nein | Button-Text. |
| `title` | `string` | nein | Überschrift (leer = keine). |
| `success_url` | `string` | nein | Weiterleitung nach Erfolg. |
| `theme` | `string` | nein | light \| dark |
| `accent` | `string` | nein | Akzentfarbe als Hex ohne #. |

```bash
curl "https://persolz.com/api/v1/subscribe/9f2c4e6a8b0d1f3e5a7c9e1b3d5f7a9c/form?fields=first_name&success_url=https%3A%2F%2Fkartenherz.com%2Fdanke"
```

**Antwort 200** – HTML-Seite (text/html)

```html
<!doctype html><html lang="de">…</html>
```

Mögliche Fehler: 404 `not_found`.

<a id="ref-integrations"></a>
### Integrationen

Zahlungsanbieter (Digistore24, CopeCart) übertragen Käufer automatisch in Persolz.

Die Adressen mit Deinem persönlichen Integrations-Token findest Du unter API & Doku → Integrationen. Dort legst Du auch Liste, Seifenoper, Tags und das Secret fest.

<a id="integrations-digistore24"></a>
#### Digistore24 IPN

`POST /api/v1/integrations/digistore24/{token}`

Öffentlich (kein API-Schlüssel)

Empfänger für die Digistore24-IPN (Instant Payment Notification). Trage die URL mit Deinem persönlichen Integrations-Token (siehe „API & Doku“ → Integrationen) in Digistore24 ein. Ist eine IPN-Passphrase hinterlegt, wird die Signatur „sha_sign“ geprüft. Zahlungsereignisse (on_payment, on_rebill_resumed) übernehmen den Käufer als Kontakt in die gewählte Liste (optional Start einer Seifenoper), on_refund / on_chargeback / on_payment_missed / on_rebill_cancelled setzen Tags. Antwort: „OK“ (text/plain).

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `token` | `string` | ja | Dein persönlicher Integrations-Token. |

**Request-Body** (`application/x-www-form-urlencoded`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `event` | `string` | ja | on_payment, on_refund, on_chargeback, on_payment_missed, on_rebill_resumed, on_rebill_cancelled, connection_test … |
| `email` | `string` | ja | E-Mail des Käufers. |
| `address_first_name` | `string` | nein | Vorname. |
| `address_last_name` | `string` | nein | Nachname. |
| `product_id` | `string` | nein | Produkt-ID. |
| `product_name` | `string` | nein | Produktname. |
| `order_id` | `string` | nein | Bestell-ID. |
| `sha_sign` | `string` | nein | Signatur (SHA-512 über die sortierten Parameter + Passphrase). |

```bash
curl -X POST https://persolz.com/api/v1/integrations/digistore24/0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a \
  --data-urlencode "event=on_payment" \
  --data-urlencode "email=anna@beispiel.de" \
  --data-urlencode "address_first_name=Anna" \
  --data-urlencode "address_last_name=Schmidt" \
  --data-urlencode "product_id=12345" \
  --data-urlencode "product_name=Kartenset-Abo" \
  --data-urlencode "order_id=ABCD1234"
```

**Antwort 200** – text/plain „OK“

```text
OK
```

Mögliche Fehler: 403 `forbidden`, 404 `not_found`, 422 `validation_error`.

<a id="integrations-copecart"></a>
#### CopeCart IPN

`POST /api/v1/integrations/copecart/{token}`

Öffentlich (kein API-Schlüssel)

Empfänger für CopeCart-IPN (JSON). Ist ein IPN-Secret hinterlegt, wird der Header „X-Copecart-Signature“ (Base64-HMAC-SHA256 über den Body) geprüft. payment.made / payment.recurring übernehmen den Käufer als Kontakt (Liste + optional Seifenoper), payment.refunded / payment.charged_back / payment.failed / payment.recurring.cancelled setzen Tags.

**Pfad-Parameter**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `token` | `string` | ja | Dein persönlicher Integrations-Token. |

**Header**

| Name | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `X-Copecart-Signature` | `string` | nein | Base64(HMAC-SHA256(Body, Secret)) – Pflicht, wenn ein Secret hinterlegt ist. |

**Request-Body** (`application/json`)

| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
| `event_type` | `string` | ja | payment.made, payment.refunded, payment.charged_back, payment.failed, payment.recurring, payment.recurring.cancelled … |
| `buyer_email` | `string` | ja | E-Mail des Käufers. |
| `buyer_firstname` | `string` | nein | Vorname. |
| `buyer_lastname` | `string` | nein | Nachname. |
| `product_id` | `string` | nein | Produkt-ID. |
| `product_name` | `string` | nein | Produktname. |
| `order_id` | `string` | nein | Bestell-ID. |

```bash
curl -X POST https://persolz.com/api/v1/integrations/copecart/0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a \
  -H "X-Copecart-Signature: k1R0...=" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "payment.made",
    "buyer_email": "anna@beispiel.de",
    "buyer_firstname": "Anna",
    "buyer_lastname": "Schmidt",
    "product_name": "Kartenset-Abo",
    "order_id": "CC-4711"
  }'
```

**Antwort 200** – OK

| Feld | Typ | Beschreibung |
|---|---|---|
| `data.received` | `boolean` | Die Zahlungsbenachrichtigung wurde angenommen. |
| `data.action` | `string` | Was passiert ist: contact_upserted, tagged, ignored … |
| `data.contact_id` | `integer` | ID des angelegten bzw. aktualisierten Kontakts. |

```json
{
  "data": {
    "received": true,
    "action": "contact_upserted",
    "contact_id": 42
  },
  "meta": {
    "request_id": "req_2b9f1c7d0e3a4b5c6d7e"
  }
}
```

Mögliche Fehler: 403 `forbidden`, 404 `not_found`, 422 `validation_error`.

<a id="fehlercodes"></a>
## Fehlercodes

| HTTP | Code | Bedeutung | Was tun? |
|---|---|---|---|
| 400 | `bad_request` | Die Anfrage ist formal ungültig (z. B. kaputtes JSON). | JSON prüfen (gültig? UTF-8?) und Content-Type: application/json senden. |
| 401 | `unauthorized` | Kein, ein ungültiger oder ein widerrufener API-Schlüssel. | Header Authorization: Bearer pz_live_… prüfen; Schlüssel ggf. neu erstellen. |
| 402 | `plan_required` | Die Funktion (oder die API selbst) ist im aktuellen Tarif nicht enthalten. | Funktion im Tarif freischalten (upgrade_url in der Antwort). |
| 402 | `limit_reached` | Ein Limit des Tarifs ist erreicht (z. B. Kontakte, Listen, Tageslimit, Speicher). | Tariflimit erhöhen oder Platz schaffen; Stand mit GET /plan/limits. |
| 403 | `forbidden` | Fehlende Berechtigung (Scope), nicht erlaubte IP-Adresse oder Herkunft, gesperrtes Konto. | reason auswerten: Scope, IP oder Origin beim Schlüssel freigeben. |
| 404 | `not_found` | Die Ressource existiert nicht oder gehört nicht zu Deinem Konto. | ID prüfen – Ressourcen anderer Konten sind nicht sichtbar. |
| 405 | `method_not_allowed` | Die HTTP-Methode ist für diesen Pfad nicht erlaubt (siehe Header „Allow“). | HTTP-Methode prüfen; erlaubte Methoden stehen im Header Allow. |
| 409 | `conflict` | Konflikt mit dem aktuellen Zustand (z. B. bereits vorhanden, bereits versendet, Idempotency-Key wiederverwendet). | Aktuellen Zustand abfragen; bei Idempotency-Konflikt neuen Key verwenden. |
| 413 | `payload_too_large` | Die Anfrage oder Datei ist zu groß. | Anfrage aufteilen (max. 1.000 Einträge) bzw. Datei verkleinern (max. 10 MB). |
| 422 | `validation_error` | Eingaben sind ungültig. Details stehen je Feld in „error.fields“. | error.fields auswerten und Eingaben korrigieren. |
| 429 | `rate_limited` | Zu viele Anfragen. Warte die Sekunden aus „Retry-After“ ab. | Retry-After abwarten, dann erneut senden. |
| 500 | `server_error` | Unerwarteter Fehler. Bitte mit der Request-ID an den Support wenden. | Mit Pause wiederholen; bleibt es dabei, Request-ID an den Support. |
| 503 | `service_unavailable` | Ein abhängiger Dienst (z. B. KI-Anbieter) ist gerade nicht erreichbar. | Mit Pause wiederholen (z. B. KI-Anbieter kurz nicht erreichbar). |

<a id="downloads"></a>
## OpenAPI & Markdown

Die komplette API gibt es auch maschinenlesbar – für Postman, Insomnia, Code-Generatoren oder Deinen KI-Assistenten.

### Downloads

- **OpenAPI 3.1** (`/api/v1/openapi.json`): alle Endpunkte, Parameter, Beispiele, Fehler und die Webhook-Ereignisse. In Postman: *Import → Link* und die URL einfügen.
- **Markdown** (`/api-docs/markdown`): diese Dokumentation als eine Datei – praktisch zum Offline-Lesen oder um sie einem KI-Assistenten als Kontext zu geben.

**Per Kommandozeile**

```bash
# OpenAPI 3.1 (z. B. für Postman: Import → Link)
curl -o persolz-openapi.json https://persolz.com/api/v1/openapi.json

# Diese Dokumentation als Markdown (z. B. für Deinen KI-Assistenten)
curl -o persolz-api.md "https://persolz.com/api-docs/markdown"
```

<a id="changelog"></a>
## Changelog

Neue Endpunkte und Felder kommen ohne Vorankündigung dazu – bestehende Felder und Endpunkte ändern sich innerhalb von `/api/v1` nicht inkompatibel. Größere Umbrüche gäbe es nur in einer neuen Version (`/api/v2`).

#### 1.1.0 – 11.10.2026

- Neue Gruppe Posteingang mit 16 Endpunkten: Postfächer und Abruf, Mails mit Filtern (Ansicht, Kategorie, Suche, Kontakt, since) und Inhalt, Massenaktionen, Antworten, Entwürfe (senden, planen, stoppen), KI-Entwürfe, Freigaben und Kennzahlen.
- Webhook-Ereignisse inbox.received, inbox.replied, inbox.approval_needed und inbox.auto_replied.

#### 1.0.0 – 09.10.2026

- Erste öffentliche Version der REST-API v1 mit 111 Endpunkten: Kontakte, Listen, Abmeldungen, E-Mails, Newsletter, Seinfeld-Mails, Seifenopern, Vorlagen, Anhänge, SMTP, Kampagnen & Analytics, A/B-Tests, Termine, KI, Webhooks und Protokolle.
- Webhooks mit 13 Ereignissen, HMAC-SHA256-Signatur, automatischen Wiederholungen und Zustellprotokoll.
- Idempotency-Key für POST, CORS mit Origin-Freigaben, IP-Freigaben und Rate-Limits je Schlüssel.
- Öffentliche Anmelde-Endpunkte für Formulare sowie Integrationen für Digistore24 und CopeCart.
- OpenAPI-3.1-Beschreibung, API-Konsole und diese Dokumentation (auch als Markdown).
