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

Delivery Insight: delivery status per recipient via REST API, live stream, webhooks and widget

Delivery Insight shows an application that sends through nextxch (POST /api/v1/send) the live state of its own mails down to each recipient: delivered, in transit, retrying with reason and next attempt, bounced, suppressed or expired. It includes a status API, batch summary, live stream (Server-Sent Events), signed webhooks, interventions in the queue (stop, retry, reschedule, redirect), bounce classification with a suppression list per application and an embeddable status widget. Base URL https://<server>/api/v1, machine-readable as OpenAPI 3.1 under /api/v1/openapi.json. Part of the public API and therefore from Business.

Operational Tip & Architecture Note

Privacy: Delivery Insight reports only what happens between the mail servers. There is no open tracking and no click tracking, no tracking pixels and no rewritten links. Metadata goes back only to you and is never logged; events for the stream are kept 72 hours, sends and history 30 days. A user key sees only the mails of its account, a tenant key only what it sent itself; foreign ids answer 404 like unknown ones.

1. Quick start: create a key, send, read the status

Admin > API & Integrations > integration keys: scopes mail.send and delivery.read (plus webhooks.manage for webhooks and delivery.write for interventions and suppression lists) and a sender address of its own for the application. The key smk_... is shown once. Alternatively an OAuth 2.0 token from POST /api/v1/oauth/token (client credentials, valid for 1 hour).

Reference / Configuration
# 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. Status API and batch summary

GET /api/v1/sends/{sendId} returns the send with all recipients (attempts, nextAttemptAtUtc, lastSmtpCode, lastSmtpResponse, targetMx, tls, throttleReason, provider). GET /api/v1/sends lists newest first, back to the retention at most (30 days); limit default 50, at most 200, continue with cursor = pagination.nextCursor. GET /api/v1/batches/{batchId} sums up all sends of a batch for a progress display.

Reference / Configuration
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. History and live stream (SSE) with Last-Event-ID

Every state change is an event delivery.queued, delivery.deferred, delivery.delivered, delivery.bounced, delivery.suppressed, delivery.expired, delivery.cancelled or delivery.redirected with a running number seq. The history GET /api/v1/events returns oldest first (up to 30 days, limit up to 500). The stream GET /api/v1/events/stream sends id, event and data; every 5 seconds a comment (: ping) keeps proxies open. Resume with the Last-Event-ID header (or ?lastEventId=) for events up to 72 hours back; the server suggests retry: 3000. API keys do not belong in browsers: use the status widget there.

Reference / Configuration
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. Register webhooks and verify the signature

POST /api/v1/webhooks (scope webhooks.manage, for delivery events additionally delivery.read) answers 201 with the signing secret whsec_..., only in this answer. Delivery events must be named explicitly, * does not include them. batchSeconds 1 to 60 batches events into a JSON array (X-Nextxch-Event: delivery.batch, at most 500); 0 sends each on its own. Signature: X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + raw body)). Always use the raw body, compare in constant time and reject old timestamps (older than 5 minutes). Answer with 2xx within 10 seconds; otherwise nextxch retries after 30 s, 2 min, 10 min, 1 h and 6 h with the same event id.

Reference / Configuration
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: stop, retry, reschedule, redirect

Scope delivery.write. Affects only open recipients (scheduled, queued, deferred); others are skipped with reason final, in_flight or not_in_queue. Retry and reschedule apply per queue item, all open recipients of the same item move along. Reschedule at most 30 days ahead, batch calls up to 10,000 recipients. Redirect creates a new recipient (event delivery.redirected, at most 3 redirects in a chain, counts against the outbound limits). Every intervention is recorded in the security audit log (DeliveryInsight.Cancel, .Retry, .Reschedule, .Redirect) with counts and ids, never with addresses.

Reference / Configuration
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. Suppression lists per application

Every sending application (the API key, otherwise the account for signed-in sends) has its own suppression list. POST /api/v1/send leaves suppressed recipients out and records them with state suppressed; if all are suppressed, the API answers 422 ALL_RECIPIENTS_SUPPRESSED. Entries have reason hard_bounce, policy, complaint, unsubscribe or manual. Read with delivery.read, change with delivery.write; adding and releasing are recorded in the audit log. The server-wide suppression list of the Reputation Shield is separate.

Reference / Configuration
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. Embed the status widget (widget token, allowed origins)

Your server fetches a widget token with the API key (scope delivery.read, no sign-in session) via POST /api/v1/delivery/widget-token: exactly one sendId or batchId, ttlSeconds 60 to 86400 (default 3600), up to 10 allowed origins (https, http only for localhost; omitted: the origins of the key). The token nxwt_... is signed, read-only and valid only for this send or batch. Batch tokens never show addresses, includeRecipients exists only with sendId. The widget is a web component without dependencies (shadow DOM, no eval, no inline styles) and runs under a strict Content-Security-Policy. Answers carry CORS headers only for the token's origins, otherwise 403 ORIGIN_NOT_ALLOWED. Origins on the key are set with allowedOrigins via the admin API.

Reference / Configuration
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>

Settings, Default Values & Best Practices

Setting / Location Description Default Value Production Best Practice
sendId, recipientId Assigned by the server (UUID). With idempotencyKey or the Idempotency-Key header a repeat within 24 hours returns the same answer and the same sendId. Addresses the server expands itself (lists, forwarding) are not tracked one by one. - Set an idempotencyKey per business run so a network error while sending does not cause duplicate mails.
batchId 1 to 100 characters (letters, digits, . _ : -, starting with a letter or digit). Several sends with the same batchId form one batch; batch ids are per account. bat_... (vom Server) Meaningful ids such as invoices-2026-11 make filters and progress displays easier.
tags, metadata Up to 10 tags of 1 to 64 characters (stored in lower case, filter tag=); metadata as key/value pairs up to 2 KB, returned in every answer, event and webhook, never logged. - Do not put secrets or unnecessary personal data into metadata.
state (Empfängerzustand) scheduled, queued and deferred are open; delivered, bounced, suppressed, expired and cancelled are final. The status of a send is pending while a recipient is open, otherwise completed. queued progressPercent of the batch summary counts final recipients; completed is true when all are final.
throttleReason Reason while waiting: greylisting, rate_limit, provider_cooldown, provider_quota, connection, tls_required, temporary. - retry.byReason and retry.byProvider of the batch summary show which provider throttles and when it goes on.
bounceClass mailbox_unknown, mailbox_full, spam_rejected, policy, dns_failure, tls_required, temporary. Determined from the text, the enhanced status code (for example x.1.1, x.2.2, x.7.10) and as a fallback 4xx/5xx; whether a bounce is permanent is stated separately. - Mark mailbox_unknown addresses as invalid in your own application instead of retrying them.
DeliveryInsight:Verp VERP return path local+nxb-<token>@domain (34-character token from recipientId and HMAC). Matches delivery reports (DSN), free-text bounces and late bounces to exactly one recipient. Applies only to local sender domains with at most 64 characters in the local part. true Keep it on. Without VERP reports are matched by Message-ID, sender domain and recipient, only within 7 days after the last attempt.
Sperrliste je Anwendung (automatische Einträge) Permanent mailbox_unknown bounce (reason hard_bounce), repeated permanent policy rejection (policy), ARF complaint (complaint, 180 days) and RFC 8058 one-click unsubscribe (unsubscribe, unlimited). Rejections caused by DMARC, SPF, DKIM, size or the sender side never count. hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage Adjust the periods with DeliveryInsight:HardBounceSuppressionDays, PolicyBounceSuppressAfter and PolicySuppressionDays.
Rate Limits (DeliveryInsight:*) 300 requests per minute and key (per node), 5 open streams per key, widget 300 per minute and token, 3 widget streams per token and 200 per key. Answer 429 RATE_LIMITED with Retry-After or 429 TOO_MANY_STREAMS. RequestsPerMinute 300 · MaxStreamsPerKey 5 Use the stream or webhooks instead of polling at short intervals.
Aufbewahrung (RetentionDays, StreamRetentionHours) Sends, recipients and history 30 days, events for resuming the stream 72 hours; deleted afterwards. Query window without since: the last 24 hours. 30 Tage · 72 h Copy results you need longer into your own database via webhook.
Webmail & Admin In webmail the Sent folder shows the delivery status of your own API sends (Free: last 24 hours). In the admin the page #/delivery shows all sends with filters by account, key, batch and tag (Business; tenant admins see their tenant). - Stop, retry and redirect in admin and webmail use the same /api/v1 calls and are logged the same way.