#/delivery
Delivery Insight : statut de remise par destinataire via API REST, flux en direct, webhooks et widget
Delivery Insight montre à une application qui envoie via nextxch (POST /api/v1/send) l'état en direct de ses propres e-mails, jusqu'à chaque destinataire : remis, en transit, nouvelle tentative avec motif et prochaine tentative, rejeté (bounce), supprimé ou expiré. Il comprend une API de statut, un récapitulatif de lot, un flux en direct (Server-Sent Events), des webhooks signés, des interventions dans la file d'attente (arrêter, réessayer, reprogrammer, rediriger), une classification des bounces avec une liste de suppression par application et un widget de statut intégrable. URL de base https://<server>/api/v1, lisible par machine en OpenAPI 3.1 sous /api/v1/openapi.json. Fait partie de l'API publique et donc disponible à partir de Business.
Confidentialité : Delivery Insight ne rapporte que ce qui se passe entre les serveurs de messagerie. Il n'y a ni suivi des ouvertures ni suivi des clics, ni pixels de suivi ni liens réécrits. Les métadonnées ne reviennent qu'à vous et ne sont jamais journalisées ; les événements du flux sont conservés 72 heures, les envois et l'historique 30 jours. Une clé utilisateur ne voit que les e-mails de son compte, une clé de locataire uniquement ce qu'elle a elle-même envoyé ; les identifiants d'autrui renvoient 404 comme les identifiants inconnus.
1. Démarrage rapide : créer une clé, envoyer, lire le statut
Admin > API & Intégrations > clés d'intégration : scopes mail.send et delivery.read (plus webhooks.manage pour les webhooks et delivery.write pour les interventions et les listes de suppression) et une adresse d'expéditeur propre à l'application. La clé smk_... n'est affichée qu'une seule fois. Alternativement, un jeton OAuth 2.0 obtenu via POST /api/v1/oauth/token (client credentials, valable 1 heure).
# 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 de statut et récapitulatif du lot
GET /api/v1/sends/{sendId} renvoie l'envoi avec tous ses destinataires (attempts, nextAttemptAtUtc, lastSmtpCode, lastSmtpResponse, targetMx, tls, throttleReason, provider). GET /api/v1/sends liste les plus récents en premier, au plus jusqu'à la limite de conservation (30 jours) ; limit par défaut 50, au plus 200, poursuite avec cursor = pagination.nextCursor. GET /api/v1/batches/{batchId} totalise tous les envois d'un lot pour un affichage de progression.
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. Historique et flux en direct (SSE) avec Last-Event-ID
Chaque changement d'état est un événement delivery.queued, delivery.deferred, delivery.delivered, delivery.bounced, delivery.suppressed, delivery.expired, delivery.cancelled ou delivery.redirected avec un numéro séquentiel seq. L'historique GET /api/v1/events renvoie les plus anciens en premier (jusqu'à 30 jours, limit jusqu'à 500). Le flux GET /api/v1/events/stream envoie id, event et data ; toutes les 5 secondes, un commentaire (: ping) maintient les proxys ouverts. Reprenez avec l'en-tête Last-Event-ID (ou ?lastEventId=) pour les événements jusqu'à 72 heures en arrière ; le serveur suggère retry: 3000. Les clés API n'ont pas leur place dans les navigateurs : utilisez-y le widget de statut.
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. Enregistrer des webhooks et vérifier la signature
POST /api/v1/webhooks (scope webhooks.manage, pour les événements de remise en plus delivery.read) répond 201 avec le secret de signature whsec_..., uniquement dans cette réponse. Les événements de remise doivent être nommés explicitement, * ne les inclut pas. batchSeconds de 1 à 60 regroupe les événements dans un tableau JSON (X-Nextxch-Event: delivery.batch, au plus 500) ; 0 envoie chacun séparément. Signature : X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + raw body)). Utilisez toujours le corps brut, comparez en temps constant et rejetez les horodatages anciens (plus de 5 minutes). Répondez par un 2xx dans les 10 secondes ; sinon nextxch réessaie après 30 s, 2 min, 10 min, 1 h et 6 h avec le même identifiant d'événement.
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. Interventions : arrêter, réessayer, reprogrammer, rediriger
Scope delivery.write. Ne concerne que les destinataires ouverts (scheduled, queued, deferred) ; les autres sont ignorés avec le motif final, in_flight ou not_in_queue. Retry et reschedule s'appliquent par élément de la file d'attente, tous les destinataires ouverts du même élément suivent. Reprogrammation au plus 30 jours à l'avance, appels par lot jusqu'à 10 000 destinataires. Redirect crée un nouveau destinataire (événement delivery.redirected, au plus 3 redirections en chaîne, compte dans les limites d'envoi). Chaque intervention est consignée dans le journal d'audit de sécurité (DeliveryInsight.Cancel, .Retry, .Reschedule, .Redirect) avec les nombres et les identifiants, jamais avec les adresses.
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. Listes de suppression par application
Chaque application d'envoi (la clé API, sinon le compte pour les envois avec connexion) possède sa propre liste de suppression. POST /api/v1/send omet les destinataires supprimés et les enregistre avec l'état suppressed ; si tous sont supprimés, l'API répond 422 ALL_RECIPIENTS_SUPPRESSED. Les entrées ont le motif hard_bounce, policy, complaint, unsubscribe ou manual. Lecture avec delivery.read, modification avec delivery.write ; les ajouts et les levées sont consignés dans le journal d'audit. La liste de suppression du Reputation Shield, valable pour tout le serveur, est distincte.
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. Intégrer le widget de statut (jeton de widget, origines autorisées)
Votre serveur obtient un jeton de widget avec la clé API (scope delivery.read, sans session de connexion) via POST /api/v1/delivery/widget-token : exactement un sendId ou un batchId, ttlSeconds de 60 à 86 400 (par défaut 3600), jusqu'à 10 origines autorisées (https, http uniquement pour localhost ; si omises : les origines de la clé). Le jeton nxwt_... est signé, en lecture seule et valable uniquement pour cet envoi ou ce lot. Les jetons de lot n'affichent jamais d'adresses, includeRecipients n'existe qu'avec sendId. Le widget est un composant web sans dépendances (shadow DOM, sans eval, sans styles inline) et fonctionne sous une Content-Security-Policy stricte. Les réponses ne portent des en-têtes CORS que pour les origines du jeton, sinon 403 ORIGIN_NOT_ALLOWED. Les origines de la clé se définissent avec allowedOrigins via l'API d'administration.
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>
Paramètres, valeurs par défaut & bonnes pratiques
| Paramètre / emplacement | Description | Valeur par défaut | Bonne pratique en production |
|---|---|---|---|
sendId, recipientId |
Attribué par le serveur (UUID). Avec idempotencyKey ou l'en-tête Idempotency-Key, une répétition dans les 24 heures renvoie la même réponse et le même sendId. Les adresses que le serveur développe lui-même (listes, transferts) ne sont pas suivies individuellement. | - |
Définissez une idempotencyKey par exécution métier, afin qu'une erreur réseau pendant l'envoi ne provoque pas d'e-mails en double. |
batchId |
1 à 100 caractères (lettres, chiffres, . _ : -, commençant par une lettre ou un chiffre). Plusieurs envois avec le même batchId forment un lot ; les identifiants de lot sont propres à chaque compte. | bat_... (vom Server) |
Des identifiants parlants comme invoices-2026-11 facilitent les filtres et les affichages de progression. |
tags, metadata |
Jusqu'à 10 tags de 1 à 64 caractères (stockés en minuscules, filtre tag=) ; metadata sous forme de paires clé/valeur jusqu'à 2 Ko, renvoyées dans chaque réponse, événement et webhook, jamais journalisées. | - |
Ne placez pas de secrets ni de données personnelles superflues dans les métadonnées. |
state (Empfängerzustand) |
scheduled, queued et deferred sont ouverts ; delivered, bounced, suppressed, expired et cancelled sont finaux. Le statut d'un envoi est pending tant qu'un destinataire est ouvert, sinon completed. | queued |
progressPercent du récapitulatif du lot compte les destinataires à l'état final ; completed vaut true lorsque tous sont à l'état final. |
throttleReason |
Motif de l'attente : greylisting, rate_limit, provider_cooldown, provider_quota, connection, tls_required, temporary. | - |
retry.byReason et retry.byProvider du récapitulatif du lot indiquent quel fournisseur limite le débit et quand l'envoi reprend. |
bounceClass |
mailbox_unknown, mailbox_full, spam_rejected, policy, dns_failure, tls_required, temporary. Déterminé à partir du texte, du code de statut étendu (par exemple x.1.1, x.2.2, x.7.10) et, à défaut, de 4xx/5xx ; le caractère permanent d'un bounce est indiqué séparément. | - |
Marquez les adresses mailbox_unknown comme invalides dans votre propre application au lieu de réessayer. |
DeliveryInsight:Verp |
Chemin de retour VERP local+nxb-<token>@domain (jeton de 34 caractères issu de recipientId et d'un HMAC). Associe les rapports de remise (DSN), les bounces en texte libre et les bounces tardifs à exactement un destinataire. S'applique uniquement aux domaines d'expéditeur locaux dont la partie locale compte au plus 64 caractères. | true |
Laissez-le activé. Sans VERP, les rapports sont associés par Message-ID, domaine de l'expéditeur et destinataire, uniquement dans les 7 jours suivant la dernière tentative. |
Sperrliste je Anwendung (automatische Einträge) |
Bounce permanent mailbox_unknown (motif hard_bounce), refus permanent répété pour raison de politique (policy), plainte ARF (complaint, 180 jours) et désabonnement en un clic selon RFC 8058 (unsubscribe, illimité). Les refus dus à DMARC, SPF, DKIM, à la taille ou au côté expéditeur ne comptent jamais. | hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage |
Ajustez les durées avec DeliveryInsight:HardBounceSuppressionDays, PolicyBounceSuppressAfter et PolicySuppressionDays. |
Rate Limits (DeliveryInsight:*) |
300 requêtes par minute et par clé (par nœud), 5 flux ouverts par clé, widget : 300 par minute et par jeton, 3 flux de widget par jeton et 200 par clé. Réponse 429 RATE_LIMITED avec Retry-After ou 429 TOO_MANY_STREAMS. | RequestsPerMinute 300 · MaxStreamsPerKey 5 |
Utilisez le flux ou les webhooks plutôt que d'interroger à intervalles courts. |
Aufbewahrung (RetentionDays, StreamRetentionHours) |
Envois, destinataires et historique 30 jours, événements pour la reprise du flux 72 heures ; supprimés ensuite. Fenêtre de requête sans since : les dernières 24 heures. | 30 Tage · 72 h |
Copiez les résultats dont vous avez besoin plus longtemps dans votre propre base de données via un webhook. |
Webmail & Admin |
Dans le Webmail, le dossier Éléments envoyés affiche le statut de remise de vos propres envois par API (Free : dernières 24 heures). Dans l'administration, la page #/delivery affiche tous les envois avec des filtres par compte, clé, lot et tag (Business ; les administrateurs de locataire voient leur locataire). | - |
Arrêter, réessayer et rediriger dans l'administration et le Webmail utilisent les mêmes appels /api/v1 et sont journalisés de la même manière. |