Capítulo 15a Clientes, groupware & CLI Admin: #/delivery

Delivery Insight: status de entrega por destinatário via REST API, stream ao vivo, webhooks e widget

O Delivery Insight mostra a uma aplicação que envia pelo nextxch (POST /api/v1/send) o estado ao vivo dos próprios e-mails até cada destinatário: entregue, em trânsito, nova tentativa com motivo e próxima tentativa, devolvido (bounce), suprimido ou expirado. Inclui uma API de status, resumo do lote, stream ao vivo (Server-Sent Events), webhooks assinados, intervenções na fila (parar, tentar novamente, reagendar, redirecionar), classificação de bounces com uma lista de supressão por aplicação e um widget de status incorporável. URL base https://<server>/api/v1, legível por máquina como OpenAPI 3.1 em /api/v1/openapi.json. Faz parte da API pública e, portanto, está disponível a partir do Business.

Dica operacional & nota de arquitetura

Privacidade: o Delivery Insight informa apenas o que acontece entre os servidores de e-mail. Não há rastreamento de abertura nem de cliques, nem pixels de rastreamento, nem links reescritos. Os metadados voltam somente para você e nunca são registrados em log; os eventos do stream são mantidos por 72 horas, envios e histórico por 30 dias. Uma chave de usuário vê apenas os e-mails da própria conta, uma chave de locatário apenas o que ela mesma enviou; ids alheios respondem 404, como ids desconhecidos.

1. Início rápido: criar uma chave, enviar, ler o status

Admin > API & Integrations > chaves de integração: escopos mail.send e delivery.read (mais webhooks.manage para webhooks e delivery.write para intervenções e listas de supressão) e um endereço de remetente próprio para a aplicação. A chave smk_... é exibida uma única vez. Alternativamente, um token OAuth 2.0 de POST /api/v1/oauth/token (client credentials, válido por 1 hora).

Referência / configuração
# 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 status e resumo do lote

GET /api/v1/sends/{sendId} retorna o envio com todos os destinatários (attempts, nextAttemptAtUtc, lastSmtpCode, lastSmtpResponse, targetMx, tls, throttleReason, provider). GET /api/v1/sends lista do mais recente para o mais antigo, retrocedendo no máximo até o limite de retenção (30 dias); limit padrão 50, no máximo 200; continue com cursor = pagination.nextCursor. GET /api/v1/batches/{batchId} totaliza todos os envios de um lote para uma exibição de progresso.

Referência / configuração
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. Histórico e stream ao vivo (SSE) com Last-Event-ID

Cada mudança de estado é um evento delivery.queued, delivery.deferred, delivery.delivered, delivery.bounced, delivery.suppressed, delivery.expired, delivery.cancelled ou delivery.redirected com um número sequencial seq. O histórico GET /api/v1/events retorna do mais antigo para o mais recente (até 30 dias, limit até 500). O stream GET /api/v1/events/stream envia id, event e data; a cada 5 segundos um comentário (: ping) mantém os proxies abertos. Retome com o cabeçalho Last-Event-ID (ou ?lastEventId=) para eventos de até 72 horas atrás; o servidor sugere retry: 3000. Chaves de API não devem ficar em navegadores: use ali o widget de status.

Referência / configuração
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. Registrar webhooks e verificar a assinatura

POST /api/v1/webhooks (escopo webhooks.manage, para eventos de entrega adicionalmente delivery.read) responde 201 com o segredo de assinatura whsec_..., somente nesta resposta. Eventos de entrega precisam ser nomeados explicitamente; * não os inclui. batchSeconds de 1 a 60 agrupa eventos em um array JSON (X-Nextxch-Event: delivery.batch, no máximo 500); 0 envia cada um individualmente. Assinatura: X-Nextxch-Signature = v1= + hex(HMAC-SHA256(secret, X-Nextxch-Timestamp + . + raw body)). Use sempre o corpo bruto, compare em tempo constante e rejeite timestamps antigos (com mais de 5 minutos). Responda com 2xx em até 10 segundos; caso contrário, o nextxch tenta novamente após 30 s, 2 min, 10 min, 1 h e 6 h com o mesmo id de evento.

Referência / configuração
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. Intervenções: parar, tentar novamente, reagendar, redirecionar

Escopo delivery.write. Afeta apenas destinatários em aberto (scheduled, queued, deferred); os demais são ignorados com o motivo final, in_flight ou not_in_queue. Retry e reschedule valem por item da fila: todos os destinatários em aberto do mesmo item são movidos juntos. Reagendamento de no máximo 30 dias à frente, chamadas em lote de até 10.000 destinatários. Redirect cria um novo destinatário (evento delivery.redirected, no máximo 3 redirecionamentos em cadeia, conta para os limites de saída). Cada intervenção é registrada no log de auditoria de segurança (DeliveryInsight.Cancel, .Retry, .Reschedule, .Redirect) com contagens e ids, nunca com endereços.

Referência / configuração
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. Listas de supressão por aplicação

Cada aplicação de envio (a chave de API, ou a conta para envios com login) tem sua própria lista de supressão. POST /api/v1/send omite destinatários suprimidos e os registra com o estado suppressed; se todos estiverem suprimidos, a API responde 422 ALL_RECIPIENTS_SUPPRESSED. As entradas têm o motivo hard_bounce, policy, complaint, unsubscribe ou manual. Leitura com delivery.read, alteração com delivery.write; inclusões e liberações são registradas no log de auditoria. A lista de supressão de todo o servidor do Reputation Shield é separada.

Referência / configuração
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. Incorporar o widget de status (token de widget, origens permitidas)

Seu servidor obtém um token de widget com a chave de API (escopo delivery.read, sem sessão de login) via POST /api/v1/delivery/widget-token: exatamente um sendId ou batchId, ttlSeconds de 60 a 86400 (padrão 3600), até 10 origens permitidas (https, http somente para localhost; se omitido: as origens da chave). O token nxwt_... é assinado, somente leitura e válido apenas para esse envio ou lote. Tokens de lote nunca mostram endereços; includeRecipients existe somente com sendId. O widget é um web component sem dependências (shadow DOM, sem eval, sem estilos inline) e roda sob uma Content-Security-Policy rigorosa. As respostas trazem cabeçalhos CORS apenas para as origens do token; caso contrário, 403 ORIGIN_NOT_ALLOWED. As origens da chave são definidas com allowedOrigins pela API de administração.

Referência / configuração
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>

Configurações, valores padrão e boas práticas

Configuração / local Descrição Valor padrão Boa prática para produção
sendId, recipientId Atribuído pelo servidor (UUID). Com idempotencyKey ou o cabeçalho Idempotency-Key, uma repetição dentro de 24 horas retorna a mesma resposta e o mesmo sendId. Endereços que o próprio servidor expande (listas, encaminhamentos) não são rastreados individualmente. - Defina um idempotencyKey por execução de negócio para que um erro de rede durante o envio não gere e-mails duplicados.
batchId 1 a 100 caracteres (letras, dígitos, . _ : -, começando com letra ou dígito). Vários envios com o mesmo batchId formam um lote; os ids de lote são por conta. bat_... (vom Server) Ids significativos, como invoices-2026-11, facilitam filtros e exibições de progresso.
tags, metadata Até 10 tags de 1 a 64 caracteres (armazenadas em minúsculas, filtro tag=); metadata como pares chave/valor de até 2 KB, retornados em cada resposta, evento e webhook, nunca registrados em log. - Não coloque segredos nem dados pessoais desnecessários nos metadados.
state (Empfängerzustand) scheduled, queued e deferred estão em aberto; delivered, bounced, suppressed, expired e cancelled são finais. O status de um envio é pending enquanto houver um destinatário em aberto; caso contrário, completed. queued progressPercent do resumo do lote conta os destinatários finalizados; completed é true quando todos estão finalizados.
throttleReason Motivo durante a espera: greylisting, rate_limit, provider_cooldown, provider_quota, connection, tls_required, temporary. - retry.byReason e retry.byProvider do resumo do lote mostram qual provedor está limitando e quando o envio continua.
bounceClass mailbox_unknown, mailbox_full, spam_rejected, policy, dns_failure, tls_required, temporary. Determinado a partir do texto, do código de status estendido (por exemplo, x.1.1, x.2.2, x.7.10) e, como alternativa, 4xx/5xx; se um bounce é permanente é informado separadamente. - Marque endereços mailbox_unknown como inválidos na sua própria aplicação em vez de tentar novamente.
DeliveryInsight:Verp Caminho de retorno VERP local+nxb-<token>@domain (token de 34 caracteres a partir de recipientId e HMAC). Associa relatórios de entrega (DSN), bounces em texto livre e bounces tardios a exatamente um destinatário. Aplica-se apenas a domínios de remetente locais com no máximo 64 caracteres na parte local. true Mantenha ativado. Sem VERP, os relatórios são associados por Message-ID, domínio do remetente e destinatário, somente dentro de 7 dias após a última tentativa.
Sperrliste je Anwendung (automatische Einträge) Bounce permanente mailbox_unknown (motivo hard_bounce), rejeição permanente repetida por política (policy), reclamação ARF (complaint, 180 dias) e cancelamento de inscrição com um clique conforme RFC 8058 (unsubscribe, ilimitado). Rejeições causadas por DMARC, SPF, DKIM, tamanho ou pelo lado do remetente nunca contam. hard_bounce 365 Tage · policy nach 2 Treffern 30 Tage Ajuste os prazos com DeliveryInsight:HardBounceSuppressionDays, PolicyBounceSuppressAfter e PolicySuppressionDays.
Rate Limits (DeliveryInsight:*) 300 requisições por minuto e por chave (por nó), 5 streams abertos por chave, widget 300 por minuto e por token, 3 streams de widget por token e 200 por chave. Resposta 429 RATE_LIMITED com Retry-After ou 429 TOO_MANY_STREAMS. RequestsPerMinute 300 · MaxStreamsPerKey 5 Use o stream ou webhooks em vez de consultar em intervalos curtos (polling).
Aufbewahrung (RetentionDays, StreamRetentionHours) Envios, destinatários e histórico por 30 dias, eventos para retomar o stream por 72 horas; depois são excluídos. Janela de consulta sem since: as últimas 24 horas. 30 Tage · 72 h Copie os resultados de que você precisa por mais tempo para seu próprio banco de dados via webhook.
Webmail & Admin No Webmail, a pasta Enviados mostra o status de entrega dos seus próprios envios via API (Free: últimas 24 horas). No admin, a página #/delivery mostra todos os envios com filtros por conta, chave, lote e tag (Business; administradores de locatário veem o próprio locatário). - Parar, tentar novamente e redirecionar no admin e no Webmail usam as mesmas chamadas /api/v1 e são registrados da mesma forma.