Kapitel 15a Clients, Groupware & CLI Admin: #/delivery

Delivery Insight: Zustellstatus je Empfänger per REST-API, Live-Stream, Webhooks und Widget

Delivery Insight zeigt einer Anwendung, die über nextxch sendet (POST /api/v1/send), den Live-Zustand ihrer eigenen Mails bis zu jedem Empfänger: zugestellt, unterwegs, in Wiederholung mit Grund und nächstem Versuch, unzustellbar, gesperrt oder abgelaufen. Dazu gehören Status-API, Batch-Übersicht, Live-Stream (Server-Sent Events), signierte Webhooks, Eingriffe in die Warteschlange (Stoppen, Wiederholen, Umplanen, Umleiten), Bounce-Klassifizierung mit Sperrliste je Anwendung und ein einbettbares Status-Widget. Basis-URL https://<server>/api/v1, maschinenlesbar als OpenAPI 3.1 unter /api/v1/openapi.json. Teil der öffentlichen API und damit ab Business.

Praxis-Tipp & Architektur-Hinweis

Datenschutz: Delivery Insight meldet nur, was zwischen den Mailservern passiert. Es gibt kein Öffnungs- und kein Klick-Tracking, keine Zählpixel und keine umgeschriebenen Links. Metadaten gehen nur an Sie zurück und werden nie protokolliert; Ereignisse für den Stream bleiben 72 Stunden, Sendungen und Verlauf 30 Tage. Ein Benutzer-Schlüssel sieht nur die Mails seines Kontos, ein Mandanten-Schlüssel nur, was er selbst gesendet hat; fremde Ids antworten wie unbekannte mit 404.

1. Schnellstart: Schlüssel anlegen, senden, Status lesen

Admin > API & Integrationen > Integrations-Schlüssel: Scopes mail.send und delivery.read (dazu webhooks.manage für Webhooks und delivery.write für Eingriffe und Sperrlisten) und eine eigene Absenderadresse für die Anwendung. Der Schlüssel smk_... wird nur einmal angezeigt. Alternativ ein OAuth-2.0-Token aus POST /api/v1/oauth/token (Client Credentials, 1 Stunde gültig).

Referenz / Konfiguration
# send.json
{
  "from": "noreply@getraenkeliste.example",
  "to": ["anna@gmx.de", "ben@web.de"],
  "subject": "Getränkeliste November",
  "text": "Hallo, die Liste ist fertig.",
  "batchId": "getraenkeliste-2026-11",
  "tags": ["monatlich", "getraenkeliste"],
  "metadata": { "listId": "2026-11" },
  "idempotencyKey": "getraenkeliste-2026-11-lauf-1"
}

curl -sS https://mail.firma.de/api/v1/send \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" -H "Content-Type: application/json" -d @send.json
# Antwort: sendId je Aufruf, recipientId je Empfänger, state queued oder scheduled

curl -sS https://mail.firma.de/api/v1/sends/0193a0c4-7c1e-7b8a-9f00-3c2d1e0f4a11 -H "Authorization: Bearer $NEXTXCH_API_KEY"
curl -sS https://mail.firma.de/api/v1/batches/getraenkeliste-2026-11 -H "Authorization: Bearer $NEXTXCH_API_KEY"

2. Status-API und Batch-Übersicht

GET /api/v1/sends/{sendId} liefert die Sendung mit allen Empfängern (attempts, nextAttemptAtUtc, lastSmtpCode, lastSmtpResponse, targetMx, tls, throttleReason, provider). GET /api/v1/sends listet neueste zuerst, höchstens bis zur Aufbewahrung (30 Tage); limit Standard 50, höchstens 200, weiter mit cursor = pagination.nextCursor. GET /api/v1/batches/{batchId} fasst alle Sendungen eines Batches für eine Fortschrittsanzeige zusammen.

Referenz / Konfiguration
GET /api/v1/sends?tag=monthly&status=deferred&batchId=drinklist-2026-11&since=2026-11-10T00:00:00Z&limit=50

GET /api/v1/batches/drinklist-2026-11
{
  "ok": true,
  "data": {
    "batchId": "drinklist-2026-11", "sends": 1, "total": 400,
    "delivered": 371, "inFlight": 4, "retrying": 22, "bounced": 2, "suppressed": 1,
    "expired": 0, "cancelled": 0, "completed": false, "progressPercent": 93.5,
    "nextAttemptAtUtc": "2026-11-10T08:05:02Z",
    "retry": { "total": 22, "byReason": [{ "reason": "rate_limit", "count": 15 }],
               "byProvider": [{ "provider": "GMX / WEB.DE", "count": 15 }] }
  }
}

3. Verlauf und Live-Stream (SSE) mit Last-Event-ID

Jede Zustandsänderung ist ein Ereignis delivery.queued, delivery.deferred, delivery.delivered, delivery.bounced, delivery.suppressed, delivery.expired, delivery.cancelled oder delivery.redirected mit laufender Nummer seq. Der Verlauf GET /api/v1/events liefert älteste zuerst (bis 30 Tage, limit bis 500). Der Stream GET /api/v1/events/stream sendet id, event und data; alle 5 Sekunden hält ein Kommentar (: ping) Proxys offen. Wiederaufnahme mit dem Header Last-Event-ID (oder ?lastEventId=) für Ereignisse bis 72 Stunden zurück; der Server schlägt retry: 3000 vor. API-Schlüssel gehören nicht in Browser: dort das Status-Widget verwenden.

Referenz / Konfiguration
curl -N https://mail.example.com/api/v1/events/stream?batchId=drinklist-2026-11 \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" -H "Last-Event-ID: 1041"

id: 1042
event: delivery.deferred
data: {"id":"evt_3f1c0d9a8b7e6f5a4b3c2d1e","seq":1042,"type":"delivery.deferred","data":{"recipient":"anna@gmx.de","state":"deferred","attempts":1,"nextAttemptAtUtc":"2026-11-10T08:05:02Z","lastSmtpCode":451,"throttleReason":"greylisting","provider":"GMX / WEB.DE"}}

: ping

# History (next page with after=pagination.nextCursor):
GET /api/v1/events?after=1000&batchId=drinklist-2026-11&limit=500

4. Webhooks registrieren und Signatur prüfen

POST /api/v1/webhooks (Scope webhooks.manage, für Zustellereignisse zusätzlich delivery.read) antwortet 201 mit dem Signatur-Geheimnis whsec_..., nur in dieser Antwort. Zustellereignisse müssen ausdrücklich genannt werden, * schließt sie nicht ein. batchSeconds 1 bis 60 bündelt Ereignisse zu einem JSON-Array (X-Nextxch-Event: delivery.batch, höchstens 500); 0 sendet einzeln. Signatur: X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + roher Body)). Immer den rohen Body verwenden, in konstanter Zeit vergleichen und alte Zeitstempel (älter als 5 Minuten) verwerfen. Antworten Sie innerhalb von 10 Sekunden mit 2xx; sonst wiederholt nextxch nach 30 s, 2 min, 10 min, 1 h und 6 h mit derselben Ereignis-id.

Referenz / Konfiguration
POST /api/v1/webhooks
{"url":"https://drinklist.example/hooks/nextxch",
 "events":["delivery.deferred","delivery.delivered","delivery.bounced","delivery.expired"],
 "batchSeconds":10}
# Header: X-Nextxch-Event, X-Nextxch-Event-Id, X-Nextxch-Delivery, X-Nextxch-Webhook-Id, X-Nextxch-Timestamp, X-Nextxch-Signature
# Log: GET /api/v1/webhooks/{id}/deliveries · Test: POST /api/v1/webhooks/{id}/test

// C#
static bool VerifyWebhook(string secret, string timestamp, string signature, byte[] rawBody)
{
    if (!long.TryParse(timestamp, out long ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    string expected = "v1=" + Convert.ToHexStringLower(hmac.ComputeHash([.. Encoding.ASCII.GetBytes(ts + "."), .. rawBody]));
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature.Trim()));
}

// PHP
function verify_webhook(string $secret, string $timestamp, string $signature, string $rawBody): bool
{
    if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) return false;
    return hash_equals('v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret), trim($signature));
}

# Python
def verify_webhook(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature.strip())

// JavaScript (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, timestamp, signature, rawBody) {
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = Buffer.from('v1=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'));
  const given = Buffer.from(String(signature).trim());
  return expected.length === given.length && timingSafeEqual(expected, given);
}

5. Eingriffe: Stoppen, Wiederholen, Umplanen, Umleiten

Scope delivery.write. Wirkt nur auf offene Empfänger (scheduled, queued, deferred); andere werden mit reason final, in_flight oder not_in_queue übersprungen. Wiederholen und Umplanen gelten je Warteschlangen-Eintrag, alle offenen Empfänger desselben Eintrags ziehen mit. Umplanen höchstens 30 Tage in die Zukunft, Batch-Aufrufe bis 10 000 Empfänger. Umleiten legt einen neuen Empfänger an (Ereignis delivery.redirected, höchstens 3 Umleitungen in Folge, zählt gegen die Versandlimits). Jeder Eingriff steht im Sicherheits-Audit-Log (DeliveryInsight.Cancel, .Retry, .Reschedule, .Redirect) mit Anzahlen und Ids, nie mit Adressen.

Referenz / Konfiguration
POST /api/v1/sends/{sendId}/cancel                                   # ohne Body / no body
POST /api/v1/sends/{sendId}/retry
POST /api/v1/batches/{batchId}/reschedule      {"sendAtUtc":"2026-11-11T06:00:00Z"}
POST /api/v1/sends/{sendId}/recipients/{recipientId}/redirect   {"to":"buchhaltung@kunde.example"}

# Antwort / answer
{"action":"retry","applied":3,"skipped":1,
 "recipients":[{"recipientId":"...","email":"anna@gmx.de","state":"queued","applied":true,"reason":null,"newRecipientId":null}]}

# Fehler / errors: 400 INVALID_TIME, INVALID_ADDRESS, SAME_ADDRESS, REDIRECT_ONE_RECIPIENT
#                  409 ALREADY_DELIVERED, IN_FLIGHT, REDIRECT_LIMIT, ADDRESS_SUPPRESSED · 429 OUTBOUND_LIMIT

6. Sperrlisten je Anwendung

Jede sendende Anwendung (der API-Schlüssel, sonst das Konto bei angemeldeten Sendungen) hat eine eigene Sperrliste. POST /api/v1/send lässt gesperrte Empfänger weg und vermerkt sie mit state suppressed; sind alle gesperrt, antwortet die API mit 422 ALL_RECIPIENTS_SUPPRESSED. Einträge haben reason hard_bounce, policy, complaint, unsubscribe oder manual. Lesen mit delivery.read, Ändern mit delivery.write; Hinzufügen und Freigeben stehen im Audit-Log. Die serverweite Sperrliste des Reputation Shield ist davon getrennt.

Referenz / Konfiguration
GET    /api/v1/suppressions?q=gmx.de&reason=hard_bounce&limit=100
POST   /api/v1/suppressions   {"emails":["alt@kunde.example"],"expiresAtUtc":"2027-01-01T00:00:00Z","note":"Kunde gekündigt"}
DELETE /api/v1/suppressions/alt@kunde.example

# Eintrag / entry
{"email":"bad@hard.example","reason":"hard_bounce","bounceClass":"mailbox_unknown",
 "diagnostic":"550 5.1.1 User unknown","sendId":"...","recipientId":"...",
 "createdAtUtc":"2026-11-10T08:00:03Z","expiresAtUtc":"2027-11-10T08:00:03Z"}

7. Status-Widget einbetten (Widget-Token, erlaubte Origins)

Ihr Server holt mit dem API-Schlüssel (Scope delivery.read, keine Anmeldesitzung) ein Widget-Token über POST /api/v1/delivery/widget-token: genau eine sendId oder batchId, ttlSeconds 60 bis 86400 (Standard 3600), bis zu 10 erlaubte Origins (https, http nur für localhost; ohne Angabe die Origins des Schlüssels). Das Token nxwt_... ist signiert, nur lesend und gilt nur für diese Sendung oder diesen Batch. Batch-Tokens zeigen nie Adressen, includeRecipients gibt es nur mit sendId. Das Widget ist eine Web Component ohne Abhängigkeiten (Shadow DOM, kein eval, keine Inline-Styles) und läuft unter einer strengen Content-Security-Policy. Antworten tragen CORS-Header nur für die Origins des Tokens, sonst 403 ORIGIN_NOT_ALLOWED. Origins am Schlüssel setzen Sie mit allowedOrigins über die Admin-API.

Referenz / Konfiguration
POST /api/v1/delivery/widget-token
{"batchId":"drinklist-2026-11","ttlSeconds":3600,"origins":["https://drinklist.example"]}

<script src="https://mail.example.com/shared/delivery-widget.js" defer></script>
<nextxch-delivery-status token="nxwt_..." batch="drinklist-2026-11" lang="de" theme="auto"></nextxch-delivery-status>

<style>
  nextxch-delivery-status { --nextxch-accent: #2563b0; --nextxch-radius: 12px; }
  @media (prefers-color-scheme: dark) { nextxch-delivery-status { --nextxch-accent: #7fb2f0; } }
</style>
<script>
  const el = document.querySelector('nextxch-delivery-status');
  el.addEventListener('nextxch-delivery-error', async e => {
    if (e.detail.code === 'WIDGET_TOKEN_EXPIRED') el.token = await fetch('/my-app/widget-token').then(r => r.text());
  });
</script>

Einstellungen, Standardwerte & Best Practices

Einstellung / Ort Funktion Standardwert Best Practice im Betrieb
sendId, recipientId Vom Server vergeben (UUID). Mit idempotencyKey oder dem Header Idempotency-Key liefert eine Wiederholung innerhalb von 24 Stunden dieselbe Antwort und dieselbe sendId. Adressen, die der Server selbst erweitert (Verteiler, Weiterleitungen), werden nicht einzeln verfolgt. - Für jeden fachlichen Lauf einen eigenen idempotencyKey setzen, damit ein Netzwerkfehler beim Senden keine Doppelmails erzeugt.
batchId 1 bis 100 Zeichen (Buchstaben, Ziffern, . _ : -, Beginn mit Buchstabe oder Ziffer). Mehrere Sendungen mit derselben batchId bilden einen Batch; Batch-Ids gelten je Konto. bat_... (vom Server) Sprechende Ids wie rechnungen-2026-11 erleichtern Filter und Fortschrittsanzeigen.
tags, metadata Bis zu 10 Tags mit 1 bis 64 Zeichen (klein gespeichert, Filter tag=); metadata als Schlüssel/Wert-Paare bis 2 KB, in jeder Antwort, jedem Ereignis und Webhook enthalten, nie im Log. - Keine Geheimnisse und keine unnötigen personenbezogenen Daten in metadata ablegen.
state (Empfängerzustand) scheduled, queued und deferred sind offen; delivered, bounced, suppressed, expired und cancelled sind endgültig. Der status einer Sendung ist pending, solange ein Empfänger offen ist, sonst completed. queued progressPercent der Batch-Übersicht zählt endgültige Empfänger; completed ist true, wenn alle endgültig sind.
throttleReason Grund beim Warten: greylisting, rate_limit, provider_cooldown, provider_quota, connection, tls_required, temporary. - retry.byReason und retry.byProvider der Batch-Übersicht zeigen, welcher Anbieter drosselt und wann es weitergeht.
bounceClass mailbox_unknown, mailbox_full, spam_rejected, policy, dns_failure, tls_required, temporary. Ermittelt aus Text, erweitertem Statuscode (z. B. x.1.1, x.2.2, x.7.10) und als Rückfall 4xx/5xx; ob ein Bounce endgültig ist, steht getrennt davon. - mailbox_unknown-Adressen in der eigenen Anwendung als ungültig markieren, statt sie weiter zu versuchen.
DeliveryInsight:Verp VERP-Rücklaufadresse local+nxb-<token>@domain (34 Zeichen Token aus recipientId und HMAC). Ordnet Zustellberichte (DSN), Bounces im Freitext und späte Bounces genau einem Empfänger zu. Gilt nur für lokale Absenderdomänen mit höchstens 64 Zeichen im lokalen Teil. true Eingeschaltet lassen. Ohne VERP werden Berichte über die Message-ID, die Absenderdomäne und den Empfänger zugeordnet, nur innerhalb von 7 Tagen nach dem letzten Versuch.
Sperrliste je Anwendung (automatische Einträge) Endgültiger mailbox_unknown-Bounce (reason hard_bounce), wiederholte endgültige policy-Ablehnung (policy), ARF-Beschwerde (complaint, 180 Tage) und RFC-8058-Abmeldung per Klick (unsubscribe, unbefristet). Ablehnungen wegen DMARC, SPF, DKIM, Größe oder Absenderseite zählen nie. hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage Fristen per DeliveryInsight:HardBounceSuppressionDays, PolicyBounceSuppressAfter und PolicySuppressionDays anpassen.
Rate Limits (DeliveryInsight:*) 300 Anfragen pro Minute und Schlüssel (je Knoten), 5 offene Streams je Schlüssel, Widget 300 pro Minute und Token, 3 Widget-Streams je Token und 200 je Schlüssel. Antwort 429 RATE_LIMITED mit Retry-After oder 429 TOO_MANY_STREAMS. RequestsPerMinute 300 · MaxStreamsPerKey 5 Stream oder Webhooks statt kurzer Abfrage-Intervalle verwenden.
Aufbewahrung (RetentionDays, StreamRetentionHours) Sendungen, Empfänger und Verlauf 30 Tage, Ereignisse für die Wiederaufnahme des Streams 72 Stunden; danach gelöscht. Abfragefenster ohne since: die letzten 24 Stunden. 30 Tage · 72 h Ergebnisse, die länger gebraucht werden, per Webhook in die eigene Datenbank übernehmen.
Webmail & Admin Im Webmail zeigt der Ordner Gesendet den Zustellstatus der eigenen API-Sendungen (Free: letzte 24 Stunden). Im Admin zeigt die Seite #/delivery alle Sendungen mit Filter nach Konto, Schlüssel, Batch und Tag (Business; Mandanten-Admins sehen ihren Mandanten). - Stoppen, Wiederholen und Umleiten in Admin und Webmail nutzen dieselben /api/v1-Aufrufe und werden genauso protokolliert.