Skip to main content

Delivery receipts

A 202 Accepted from POST /v1/events/trigger indicates that Nervly accepted the notification and durably enqueued it onto JetStream. Delivery receipts determine what happened next.

Checking delivery status​

At launch, Nervly supports two primary methods for customers to inspect delivery status:

1. Polling via API & SDK​

You can query any event by its ID using the public status endpoint:

curl https://api.nervly.io/v1/events/evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8 \
-H "Authorization: Bearer $NERVLY_API_KEY"
{
"event_id": "evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8",
"event_name": "order-confirmed",
"subscriber_id": "sub_4920a",
"status": "DELIVERED",
"channel": "sms",
"provider": "termii",
"priority": 3,
"attempts": 1,
"cost_micro_usd": 5000,
"test_mode": false,
"variables_keys": ["orderId", "amount"],
"created_at": "2026-09-11T21:00:00.123Z",
"updated_at": "2026-09-11T21:00:01.456Z"
}

Or with the official TypeScript SDK (@nervly/sdk):

import { Nervly } from '@nervly/sdk';

const nervly = new Nervly({ apiKey: process.env.NERVLY_API_KEY });
const receipt = await nervly.events.get('evt_9c4f1a2b3d5e6f708192a3b4c5d6e7f8');

console.log(`Status: ${receipt.status}, Provider: ${receipt.provider}, Cost: $${receipt.cost_micro_usd / 1e6}`);

2. Live stream in the dashboard​

In app.nervly.io/messages, your team can search, filter, and inspect deliveries with real-time status badges (DELIVERED, FAILED, SUPPRESSED). Clicking any message opens the drawer to display the chronological delivery timeline in order.

Outbound webhooks roadmap

Nervly provides immediate status polling via GET /v1/events/{eventId} at launch. Direct outbound webhooks that push delivery status events to your own HTTP endpoints are not part of the launch surface — they are scheduled for Month 6 or later. This is product sequencing, not an architecture blocker (ADR-010 covers deploy topology, not webhooks). We deferred direct outbound webhooks to get signature rotation, dead-letter retry queues, and replay protection right without rushing the launch.

What is on the launch surface is the local CLI's forwarding tunnel: nervly forward webhooks --to <url> (part of @nervly/cli) dials out to a Nervly-operated relay, the platform posts your real delivery-status events down the established tunnel, and the CLI re-signs each event locally for your receiver. That path delivers real delivery events at v1 without an inbound endpoint on your side and without an assumed platform-signed outbound scheme. It is a consumer of the delivery-events stream described below — not a second webhook product. Do not build against an assumed direct outbound signature scheme yet; this page will document it when direct delivery ships.

Inbound provider callbacks​

The webhook surface Nervly operates today is inbound. When a downstream provider (Termii, Resend, ZeptoMail, Infobip, Twilio, …) finishes a delivery, it sends the outcome back to:

POST https://api.nervly.io/v1/webhooks/{provider}

Authentication on this route is by signature, not API key — providers cannot present your bearer token — so it sits outside the authenticated API surface. Nervly authenticates the provider's callback against the raw request body before it parses any JSON — a native provider-issued signature where the provider has one, otherwise a platform-configured shared-secret header — then normalises the callback into one receipt shape. A provider name outside the accepted set is refused 404: there is no unauthenticated catch-all.

The secret the signature is checked against is your workspace's own signing secret for that provider. In the console, open Providers, and on the provider card set (or rotate) its Webhook secret — the value issued by that provider for the webhook endpoint (for SendGrid, the base64 verification public key SendGrid hands you). It is write-only: the console and the API return only a masked hint, never the value. Nervly looks the secret up for the workspace the callback belongs to and verifies the raw body against it. Platform-operated providers (where Nervly runs the provider account) verify against the platform's deployment-wide secret when the workspace is on the shared floor; a workspace with a provisioned child account for that provider verifies against the child's own secret, with no fallback to the shared secret. A callback that cannot be matched to a secret — or that fails verification — is rejected 401 and never parsed. When you rotate a secret, the previous value keeps verifying for a 24-hour window so callbacks already in flight with the old secret are not dropped.

ProviderSignature headerAlgorithmSigned contentEncoding
TermiiX-Termii-SignatureHMAC-SHA256, secret as UTF-8 bytesraw request bodylowercase hex
Resend (Svix)svix-signature (+ svix-id, svix-timestamp)HMAC-SHA256, base64-decoded whsec_… secret{svix-id}.{svix-timestamp}.{body}base64, v1, prefix
ZeptoMailproducer-signatureHMAC-SHA256, auth key as UTF-8 bytesraw request bodybase64 (ts=…;s=…;s-algorithm=HmacSHA256)
InfobipX-Hub-SignatureHMAC-SHA256, secret as UTF-8 bytesraw request bodybase64 or hex (optional sha256= prefix)
TwilioX-Twilio-SignatureHMAC-SHA1 (Twilio's RequestValidator)request URL followed by sorted form parametersbase64
SinchX-Sinch-SignatureHMAC-SHA256, secret as UTF-8 bytesraw request bodyhex or base64
WhatsApp (Meta)X-Hub-Signature-256HMAC-SHA256, app secret as UTF-8 bytesraw request bodyhex, sha256= prefix
Africa's TalkingX-AfricasTalking-SignatureHMAC-SHA256, platform-configured shared secretraw request bodyhex
SendchampX-Sendchamp-SignatureHMAC-SHA256, platform-configured shared secretraw request bodyhex
PostmarkX-Postmark-SignatureHMAC-SHA256, platform-configured shared secretraw request bodyhex
SendGridX-Twilio-Email-Event-Webhook-Signature (+ X-Twilio-Email-Event-Webhook-Timestamp)ECDSA P-256 over SHA256(timestamp ‖ body), with SendGrid's verification public keytimestamp header string followed by the raw request bodybase64 ASN.1 DER
Cloudflare (platform Worker forward — see below)X-Nervly-Cloudflare-SignatureHMAC-SHA256, CLOUDFLARE_WEBHOOK_SECRET as UTF-8 bytesraw request bodylowercase hex, optional sha256= prefix

Africa's Talking, Sendchamp and Postmark do not sign their callbacks natively; for those the header is a platform-configured shared-secret control (a value injected at the operator's callback relay or by the provider's custom-header setting), verified over the raw body exactly like a native scheme. SendGrid's signature is a genuine provider-issued ECDSA signature verified with the public key from its Event Webhook settings.

Every timestamped scheme (Svix, ZeptoMail, Termii's optional X-Termii-Timestamp, and SendGrid's …-Timestamp) enforces a five-minute replay window. Twilio's, Sinch's, Africa's Talking's, Sendchamp's and Postmark's schemes carry no timestamp; replay is bounded by the durable receipt dedupe Nervly applies downstream, so a replayed callback is a no-op.

Cloudflare: forwarded by the platform's Worker​

Cloudflare Email does not sign delivery callbacks itself. Its Email Sending lifecycle events (delivered, deferred, bounced, failed, rejected, complained) are published to a Cloudflare Queue through an Event Subscription. A Go service outside the Cloudflare account cannot consume that Queue directly, so the platform runs a small Cloudflare Worker (nervly-email-events) that consumes the Queue, HMAC-signs each event with the shared CLOUDFLARE_WEBHOOK_SECRET, and forwards it to POST /v1/webhooks/cloudflare.

The gateway verifies that signature on the raw body (the X-Nervly-Cloudflare-Signature row above), then normalises the event like any other receipt: delivered becomes DELIVERED; a hard bounce becomes SUPPRESSED and a spam complaint becomes COMPLAINED, both recording the recipient in the suppression registry; deferred and a soft bounce become the non-terminal BOUNCED_SOFT; and failed and rejected become terminal FAILED. The Worker's deployment notes, event vocabulary and retry/DLQ behaviour live alongside it at nervly-base/infrastructure/cloudflare/workers/email-events/README.md.

Verify a raw-body HMAC-SHA256 signature​

The rule that matters most: verify the raw, unparsed request body bytes. Re-serialising a parsed object (JSON.stringify(JSON.parse(body)), json.dumps(json.loads(body))) can reorder keys, change whitespace, and rewrite Unicode escapes, so the HMAC will not match even though the JSON is equivalent. Read the body as bytes, verify, then decode.

The examples below implement the Svix scheme — the one behind Resend and the richest of the set: it signs {svix-id}.{svix-timestamp}.{body} with the base64-decoded secret and compares base64 digests in constant time. To adapt to Termii, sign the raw body with the UTF-8 secret and hex-encode the digest; for ZeptoMail or Infobip, keep the raw body but use the encoding in the table above. Every snippet rejects a stale timestamp and compares the candidate in constant time rather than with == or .equals().

HMAC-SHA256 over {id}.{timestamp}.{body} with the base64-decoded secret:

#!/usr/bin/env bash
set -euo pipefail

# From the request: `svix-id`, `svix-timestamp` and `svix-signature` headers.
SVIX_ID="${SVIX_ID:?set from the svix-id header}"
SVIX_TIMESTAMP="${SVIX_TIMESTAMP:?set from the svix-timestamp header}"
SVIX_SIGNATURE="${SVIX_SIGNATURE:?set from the svix-signature header}"
SVIX_SECRET="${SVIX_WEBHOOK_SECRET:?set to your whsec_… webhook secret}"

# The raw body exactly as it arrived — read it as bytes, never re-encode it.
RAW_BODY="$(cat payload.json)"

EXPECTED="$(printf '%s.%s.%s' "$SVIX_ID" "$SVIX_TIMESTAMP" "$RAW_BODY" |
openssl dgst -sha256 -mac HMAC \
-macopt "hexkey:$(printf '%s' "${SVIX_SECRET#whsec_}" | base64 -d | xxd -p -c 256)" \
-binary | base64 | tr -d '\n')"

# Reject a stale timestamp (five-minute window) before trusting the MAC.
NOW="$(date +%s)"
if ((NOW - SVIX_TIMESTAMP > 300 || SVIX_TIMESTAMP - NOW > 300)); then
echo "svix-timestamp outside the five-minute tolerance" >&2
exit 1
fi

# Constant-time-ish compare: fold every byte difference into `diff` and decide
# once at the end, so the loop never short-circuits on the first mismatch.
CANDIDATE="${SVIX_SIGNATURE#v1,}"
diff=0
[[ "${#EXPECTED}" -eq "${#CANDIDATE}" ]] || diff=1
for ((i = 0; i < ${#EXPECTED} && i < ${#CANDIDATE}; i++)); do
diff=$((diff | ($(printf '%d' "'${EXPECTED:i:1}") ^ $(printf '%d' "'${CANDIDATE:i:1}"))))
done
if ((diff != 0)); then
echo "invalid webhook signature" >&2
exit 1
fi

Shell arithmetic is a convenience, not a hardened crypto runtime — production receivers should use one of the language runtimes below, whose standard libraries expose an OS-backed constant-time compare.

Golden test vector​

Reproduce this vector by hand to confirm your verifier implements the algorithm above. npm run check:snippets recomputes it on every build, so a wrong algorithm or mismatched digest fails the gate by name.

NERVLY-WEBHOOK-GOLDEN-VECTOR
secret=whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
id=msg_p5jXN8AQM9LWM0D9d97iMH2mit5o3
timestamp=1732166400
body={"type":"email.delivered","data":{"email_id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}}
signature=v1,BZ/abGUZl58eXnR/GOTaiSRh4cRt2bd9J6XCdvDRItY=

The signature is the base64 HMAC-SHA256 of the exact {id}.{timestamp}.{body} string assembled from the lines above, keyed with the base64-decoded whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw. Change any byte of the body and the digest changes.

Normalized receipt payload​

Once a callback's signature verifies, Nervly normalises every provider dialect into a single TelemetryReceiptEvent, published as JSON on its internal telemetry stream and described by the public API reference:

{
"provider": "resend",
"message_id": "49a3999c-0ce1-4ea6-ab68-afcd6dc2e794",
"recipient": "user@example.com",
"status": "DELIVERED",
"timestamp": "2026-09-17T02:22:00Z",
"raw_event_id": "dlr_1a2b3c4d5e6f70819",
"workspace_id": "ws_9f2c1d",
"bounce_reason": null
}
FieldRequiredMeaning
provideryesProvider that reported the receipt (termii, resend, zeptomail, infobip, twilio, sinch, africastalking, whatsapp, sendchamp, postmark, sendgrid, cloudflare).
message_idyesThe provider's own identifier for the message.
recipientyesRecipient address or token.
statusyesDelivery outcome: DELIVERED, BOUNCED_HARD, BOUNCED_SOFT, or COMPLAINED.
timestampyesISO 8601 / RFC 3339 time the event occurred.
raw_event_idyesNervly's identifier for this receipt (dlr_…).
workspace_idnoWorkspace the receipt belongs to, when known.
bounce_reasonnoDiagnostic text for a bounce or complaint.

This normalized event is the shape on Nervly's telemetry stream and in the API reference; it is not yet delivered directly to customer HTTP endpoints (see the outbound webhooks roadmap above) — at launch it reaches your machine through the local CLI's forwarding tunnel, nervly forward webhooks. The decoders below show how to read it — useful if you self-host Nervly or consume its telemetry, and directly applicable to the payload the CLI forwards or once direct outbound webhooks ship.

# jq reads the normalized receipt; no re-serialization is involved.
echo "$RAW_BODY" | jq -r '"\(.raw_event_id) \(.status) \(.provider) \(.timestamp)"'

Durability and telemetry​

After dispatch, the worker records delivery state and telemetry:

  • It updates the message's status (DELIVERED, FAILED, SUPPRESSED), attempt count, provider, and final channel.
  • It appends an immutable timeline step for each state transition, including error diagnostics.
  • It aggregates daily message counts and costs per workspace, channel, and provider so analytics queries do not scan the full message history.

Every record is scoped to your workspace; none of it is visible to another tenant.

Next​