Capitolo 15a Client, groupware & CLI Admin: #/delivery

Delivery Insight: stato di recapito per destinatario tramite API REST, stream live, webhook e widget

Delivery Insight mostra a un'applicazione che invia tramite nextxch (POST /api/v1/send) lo stato live delle proprie e-mail fino al singolo destinatario: recapitata, in transito, nuovo tentativo con motivo e prossimo tentativo, respinta, soppressa o scaduta. Include un'API di stato, il riepilogo del batch, uno stream live (Server-Sent Events), webhook firmati, interventi nella coda (interruzione, nuovo tentativo, ripianificazione, reindirizzamento), classificazione dei bounce con una lista di soppressione per applicazione e un widget di stato incorporabile. URL di base https://<server>/api/v1, leggibile automaticamente come OpenAPI 3.1 sotto /api/v1/openapi.json. Fa parte dell'API pubblica e quindi è disponibile da Business.

Suggerimento operativo & nota sull'architettura

Privacy: Delivery Insight riporta solo ciò che avviene tra i server di posta. Non c'è tracciamento delle aperture né dei clic, nessun pixel di tracciamento e nessun link riscritto. I metadati tornano solo a Lei e non vengono mai registrati nei log; gli eventi per lo stream vengono conservati 72 ore, invii e cronologia 30 giorni. Una chiave utente vede solo le e-mail del proprio account, una chiave tenant solo ciò che ha inviato essa stessa; gli id altrui rispondono 404 come quelli sconosciuti.

1. Avvio rapido: creare una chiave, inviare, leggere lo stato

Admin > API e integrazioni > chiavi di integrazione: ambiti mail.send e delivery.read (più webhooks.manage per i webhook e delivery.write per interventi e liste di soppressione) e un indirizzo mittente proprio per l'applicazione. La chiave smk_... viene mostrata una sola volta. In alternativa un token OAuth 2.0 da POST /api/v1/oauth/token (client credentials, valido per 1 ora).

Riferimento / configurazione
# send.json
{
  "from": "noreply@drinklist.example",
  "to": ["anna@gmx.de", "ben@web.de"],
  "subject": "Drink list November",
  "text": "Hello, the list is ready.",
  "batchId": "drinklist-2026-11",
  "tags": ["monthly", "drinklist"],
  "metadata": { "listId": "2026-11" },
  "idempotencyKey": "drinklist-2026-11-run-1"
}

curl -sS https://mail.example.com/api/v1/send \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" -H "Content-Type: application/json" -d @send.json
# Answer: sendId per call, recipientId per recipient, state queued or scheduled

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

2. API di stato e riepilogo del batch

GET /api/v1/sends/{sendId} restituisce l'invio con tutti i destinatari (attempts, nextAttemptAtUtc, lastSmtpCode, lastSmtpResponse, targetMx, tls, throttleReason, provider). GET /api/v1/sends elenca prima i più recenti, al massimo fino al limite di conservazione (30 giorni); limit predefinito 50, al massimo 200, si prosegue con cursor = pagination.nextCursor. GET /api/v1/batches/{batchId} riepiloga tutti gli invii di un batch per un indicatore di avanzamento.

Riferimento / configurazione
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. Cronologia e stream live (SSE) con Last-Event-ID

Ogni cambio di stato è un evento delivery.queued, delivery.deferred, delivery.delivered, delivery.bounced, delivery.suppressed, delivery.expired, delivery.cancelled o delivery.redirected con un numero progressivo seq. La cronologia GET /api/v1/events restituisce prima i più vecchi (fino a 30 giorni, limit fino a 500). Lo stream GET /api/v1/events/stream invia id, event e data; ogni 5 secondi un commento (: ping) mantiene aperti i proxy. Si riprende con l'header Last-Event-ID (o ?lastEventId=) per eventi fino a 72 ore indietro; il server suggerisce retry: 3000. Le chiavi API non vanno nei browser: lì usi il widget di stato.

Riferimento / configurazione
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. Registrare i webhook e verificare la firma

POST /api/v1/webhooks (ambito webhooks.manage, per gli eventi di recapito anche delivery.read) risponde 201 con il segreto di firma whsec_..., solo in questa risposta. Gli eventi di recapito devono essere indicati esplicitamente, * non li include. batchSeconds da 1 a 60 raggruppa gli eventi in un array JSON (X-Nextxch-Event: delivery.batch, al massimo 500); 0 invia ciascuno singolarmente. Firma: X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + raw body)). Usi sempre il corpo grezzo, confronti in tempo costante e rifiuti i timestamp vecchi (più di 5 minuti). Risponda con 2xx entro 10 secondi; altrimenti nextxch riprova dopo 30 s, 2 min, 10 min, 1 h e 6 h con lo stesso id evento.

Riferimento / configurazione
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. Interventi: interruzione, nuovo tentativo, ripianificazione, reindirizzamento

Ambito delivery.write. Riguarda solo i destinatari aperti (scheduled, queued, deferred); gli altri vengono saltati con motivo final, in_flight o not_in_queue. Retry e reschedule valgono per elemento della coda: tutti i destinatari aperti dello stesso elemento vengono spostati insieme. Reschedule al massimo 30 giorni avanti, chiamate batch fino a 10.000 destinatari. Redirect crea un nuovo destinatario (evento delivery.redirected, al massimo 3 reindirizzamenti in catena, conta nei limiti in uscita). Ogni intervento viene registrato nel registro di controllo della sicurezza (DeliveryInsight.Cancel, .Retry, .Reschedule, .Redirect) con conteggi e id, mai con indirizzi.

Riferimento / configurazione
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. Liste di soppressione per applicazione

Ogni applicazione mittente (la chiave API, altrimenti l'account per gli invii con accesso) ha una propria lista di soppressione. POST /api/v1/send esclude i destinatari soppressi e li registra con stato suppressed; se tutti sono soppressi, l'API risponde 422 ALL_RECIPIENTS_SUPPRESSED. Le voci hanno motivo hard_bounce, policy, complaint, unsubscribe o manual. Lettura con delivery.read, modifica con delivery.write; aggiunte e rimozioni vengono registrate nel registro di controllo. La lista di soppressione a livello di server del Reputation Shield è separata.

Riferimento / configurazione
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. Incorporare il widget di stato (token widget, origini consentite)

Il Suo server ottiene un token widget con la chiave API (ambito delivery.read, nessuna sessione di accesso) tramite POST /api/v1/delivery/widget-token: esattamente un sendId o un batchId, ttlSeconds da 60 a 86.400 (predefinito 3600), fino a 10 origini consentite (https, http solo per localhost; se omesse: le origini della chiave). Il token nxwt_... è firmato, di sola lettura e valido solo per questo invio o batch. I token di batch non mostrano mai indirizzi, includeRecipients esiste solo con sendId. Il widget è un web component senza dipendenze (shadow DOM, nessun eval, nessuno stile inline) e funziona sotto una Content-Security-Policy rigorosa. Le risposte contengono header CORS solo per le origini del token, altrimenti 403 ORIGIN_NOT_ALLOWED. Le origini della chiave si impostano con allowedOrigins tramite l'API di amministrazione.

Riferimento / configurazione
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>

Impostazioni, valori predefiniti e best practice

Impostazione / posizione Descrizione Valore predefinito Best practice per la produzione
sendId, recipientId Assegnato dal server (UUID). Con idempotencyKey o l'header Idempotency-Key una ripetizione entro 24 ore restituisce la stessa risposta e lo stesso sendId. Gli indirizzi che il server espande da sé (liste, inoltri) non vengono tracciati singolarmente. - Imposti un idempotencyKey per ogni esecuzione aziendale, così un errore di rete durante l'invio non provoca e-mail duplicate.
batchId Da 1 a 100 caratteri (lettere, cifre, . _ : -, che iniziano con una lettera o una cifra). Più invii con lo stesso batchId formano un batch; gli id di batch sono per account. bat_... (vom Server) Id significativi come invoices-2026-11 semplificano filtri e indicatori di avanzamento.
tags, metadata Fino a 10 tag da 1 a 64 caratteri (memorizzati in minuscolo, filtro tag=); metadata come coppie chiave/valore fino a 2 KB, restituiti in ogni risposta, evento e webhook, mai registrati nei log. - Non inserisca segreti o dati personali non necessari nei metadati.
state (Empfängerzustand) scheduled, queued e deferred sono aperti; delivered, bounced, suppressed, expired e cancelled sono finali. Lo stato di un invio è pending finché un destinatario è aperto, altrimenti completed. queued progressPercent del riepilogo del batch conta i destinatari in stato finale; completed è true quando tutti sono in stato finale.
throttleReason Motivo dell'attesa: greylisting, rate_limit, provider_cooldown, provider_quota, connection, tls_required, temporary. - retry.byReason e retry.byProvider del riepilogo del batch mostrano quale provider applica limitazioni e quando si riprende.
bounceClass mailbox_unknown, mailbox_full, spam_rejected, policy, dns_failure, tls_required, temporary. Determinato dal testo, dall'enhanced status code (ad esempio x.1.1, x.2.2, x.7.10) e, in mancanza, da 4xx/5xx; se un bounce è permanente viene indicato separatamente. - Contrassegni gli indirizzi mailbox_unknown come non validi nella propria applicazione invece di riprovare a inviarli.
DeliveryInsight:Verp Percorso di ritorno VERP local+nxb-<token>@domain (token di 34 caratteri da recipientId e HMAC). Associa rapporti di recapito (DSN), bounce in testo libero e bounce tardivi esattamente a un destinatario. Vale solo per domini mittente locali con al massimo 64 caratteri nella parte locale. true Lo lasci attivo. Senza VERP i rapporti vengono associati tramite Message-ID, dominio del mittente e destinatario, e solo entro 7 giorni dall'ultimo tentativo.
Sperrliste je Anwendung (automatische Einträge) Bounce permanente mailbox_unknown (motivo hard_bounce), rifiuto permanente ripetuto per policy (policy), reclamo ARF (complaint, 180 giorni) e disiscrizione con un clic secondo RFC 8058 (unsubscribe, illimitato). I rifiuti causati da DMARC, SPF, DKIM, dimensione o dal lato mittente non contano mai. hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage Adegui i periodi con DeliveryInsight:HardBounceSuppressionDays, PolicyBounceSuppressAfter e PolicySuppressionDays.
Rate Limits (DeliveryInsight:*) 300 richieste al minuto per chiave (per nodo), 5 stream aperti per chiave, widget 300 al minuto per token, 3 stream widget per token e 200 per chiave. Risposta 429 RATE_LIMITED con Retry-After oppure 429 TOO_MANY_STREAMS. RequestsPerMinute 300 · MaxStreamsPerKey 5 Usi lo stream o i webhook invece di interrogazioni periodiche a intervalli brevi.
Aufbewahrung (RetentionDays, StreamRetentionHours) Invii, destinatari e cronologia 30 giorni, eventi per riprendere lo stream 72 ore; poi vengono eliminati. Finestra di interrogazione senza since: le ultime 24 ore. 30 Tage · 72 h Copi i risultati che Le servono più a lungo nel proprio database tramite webhook.
Webmail & Admin Nella webmail la cartella Posta inviata mostra lo stato di recapito dei propri invii via API (Free: ultime 24 ore). Nell'admin la pagina #/delivery mostra tutti gli invii con filtri per account, chiave, batch e tag (Business; gli amministratori tenant vedono il proprio tenant). - Interruzione, nuovo tentativo e reindirizzamento in admin e webmail usano le stesse chiamate /api/v1 e vengono registrati allo stesso modo.