Delivery tracking
Persist the real per-channel delivery status — sent, failed, delivered, bounced — not just the in-memory SendResult. The delivery-tracking package records every send and updates it from Twilio / SES status webhooks.
send() returns a SendResult that tells you what happened right now — accepted by the transport,
or threw. But "the SMS provider accepted it" isn't "the phone received it." Carriers and mailbox
providers confirm the real outcome later, over a status webhook. @dudousxd/nestjs-notifications-delivery-tracking
persists a row per channel delivery and moves it through its lifecycle as those callbacks arrive.
pnpm add @dudousxd/nestjs-notifications-delivery-trackingThe lifecycle
Each delivery is one record with a status:
| Status | Meaning |
|---|---|
queued | Accepted for delivery, not yet handed to the transport. |
sent | The channel transport accepted it (no terminal confirmation yet). |
failed | The transport threw while sending. |
delivered | The provider confirmed delivery, via an inbound status webhook. |
bounced | The provider reported a bounce / complaint / undeliverable. |
Record every send
The package listens to the notification.sent / notification.failed events the core already emits —
no monkey-patching. Register the module (it needs @nestjs/event-emitter):
import { EventEmitterModule } from '@nestjs/event-emitter';
import { DeliveryTrackingModule } from '@dudousxd/nestjs-notifications-delivery-tracking';
@Module({
imports: [
EventEmitterModule.forRoot(),
NotificationsModule.forRoot({ /* ... */ }),
DeliveryTrackingModule.forRoot(), // in-memory store by default
],
})
export class AppModule {}Now every channel delivery writes a sent or failed record, tagged with the channel, notification
class, notifiable ref, and tenant. Query it through DeliveryTrackingService:
const failures = await tracking.list({ status: 'failed', tenantId: 'acme' });The default store keeps records in memory — great for development and tests. It holds only the
most recent 10,000 records and drops the oldest first, so older records (and webhooks for them)
are no longer matched. For production, supply a persistent store implementing
DeliveryTrackingStore (the same pattern as the database channel's store), backed by your own
table.
Close the loop with inbound webhooks
To move a record from sent to delivered/bounced, mount the provider's status callback. The
package ships controllers that verify the callback and update the matching record.
Twilio (SMS)
Twilio POSTs status callbacks signed with your auth token. The controller verifies the
X-Twilio-Signature and maps Twilio's status to the lifecycle:
import { createTwilioStatusController } from '@dudousxd/nestjs-notifications-delivery-tracking';
const TwilioStatusController = createTwilioStatusController({
authToken: process.env.TWILIO_AUTH_TOKEN!,
path: 'webhooks/twilio/status',
});
@Module({ controllers: [TwilioStatusController] })
export class WebhooksModule {}Point your Twilio messaging service's status callback URL at that path. Invalid signatures are
rejected with 403.
Provider callbacks carry no user, so these controllers are not meant to sit behind your app's
authentication: Twilio and SES/SNS are authenticated by their signatures. Both factories take decorators (e.g.
an IP-allowlist UseGuards(...)). See Authorization of the mounted routes.
AWS SES (email)
SES publishes bounce / complaint / delivery events to an SNS topic, and SNS POSTs them to your endpoint. The controller verifies every SNS message before acting on it, handles the subscription handshake, and updates the matching record:
import { createSesNotificationController } from '@dudousxd/nestjs-notifications-delivery-tracking';
const SesController = createSesNotificationController({
topicArns: [process.env.SES_EVENTS_TOPIC_ARN!], // required: the topic(s) SES publishes to
path: 'webhooks/ses',
autoConfirmSubscription: true, // GET the SubscribeURL once the confirmation is verified
});What happens to each request, following AWS's verification algorithm:
- Topic —
TopicArnmust be intopicArns. Anyone can create an SNS topic and subscribe your URL, and SNS signs their messages too, so the signature alone doesn't make an event yours. - Freshness —
Timestampmust be withinmaxMessageAgeMs(default one hour; SNS keeps the original timestamp across retries, so keep it above your retry window).falseturns it off. - Certificate URL —
SigningCertURLmust behttps://sns.<region>.amazonaws.com/…pem(…amazonaws.com.cnin China,sns.us-gov-*in GovCloud). Anything else is refused before any request is made. Certificates are fetched with a 5 s timeout (certificateTimeoutMs) and cached by URL. - Signature — the canonical string-to-sign is rebuilt for the message type and checked with
RSA:
SignatureVersion1is SHA1withRSA,2is SHA256withRSA; any other version is refused.
A message that fails any check gets 403 and nothing is recorded. Verified notifications are
deduplicated by MessageId (SNS delivers at least once) in a bounded in-memory store per
controller; pass messageIdStore ({ has, add }) to share it across instances, or false to turn
it off. A MessageId is remembered for messageIdTtlMs (default 24 hours, and never less than
maxMessageAgeMs), independently of the freshness check.
Subscription confirmation. When you subscribe the endpoint, SNS first sends a
SubscriptionConfirmation. Once verified (same checks, so only your topics), it is passed to
onSubscriptionConfirmation(message) if you set it, and its SubscribeURL is GET-ed when
autoConfirmSubscription: true (only if that URL is an https:// SNS host; redirects are refused
and the GET times out after confirmTimeoutMs, default 5 s). If that GET fails, the endpoint
answers 502, so SNS redelivers the confirmation and it is tried again. With neither, confirm
the subscription in the AWS console or with aws sns confirm-subscription using the Token.
Body parsing. SNS posts its JSON with Content-Type: text/plain. Nothing to configure: the
controller reads the raw body itself when Nest's default JSON/urlencoded parsers skip it, and
also accepts a body already read by a text parser (app.useBodyParser('text'), Fastify's
default), rawBody: true, or a JSON parser (e.g. a subscription with headerContentType: application/json). Just don't put a parser in front of the route that consumes the stream and
throws the body away.
verifySignature: false skips only the signature (the topic and timestamp checks still run). Use it
only when something in front of the route already verifies SNS signatures. For tests, inject
certificateLoader: async (url) => pem and fetch.
The verifier is exported on its own too (SnsMessageVerifier, buildSnsStringToSign,
isSnsUrl) if you consume SNS elsewhere.
Correlating sends to callbacks
A webhook says "message SM123 was delivered" — the tracker needs to know which record that is. When
a channel returns the provider's id (Twilio's MessageSid; the mail channel returns @nestjs/mail's
MailSendResult, whose providerMessageId is the SES MessageId), the listener captures it
automatically, so delivered/bounced updates just work.
Mail sent outside notifications
Mail your app sends with @nestjs/mail's Mailer directly isn't a notification, so the listener
never sees it. mailDeliveryTracking() from the /mail subpath records it from MailEvents
(sent / failed, with the provider message id, so SES bounces correlate). Mail the mail channel
sends is skipped there, so nothing is recorded twice:
import { mailDeliveryTracking } from '@dudousxd/nestjs-notifications-delivery-tracking/mail';
@Module({
imports: [DeliveryTrackingModule.forRoot(), MailModule.forRoot({ transport })],
providers: [...mailDeliveryTracking()],
})
export class AppModule {}If a transport doesn't surface its id, attach it yourself after sending and the next callback will correlate:
await store.setProviderMessageId(record.id, providerMessageId);See it in Telescope
Pair this with the Telescope watcher — sent/failed entries now carry the tenant and delivery duration, so the dashboard shows per-channel, per-tenant delivery timing alongside the persisted final status.
Live / progress notifications
Some notifications evolve — an export going 0% → 100%, a job that flips from "running" to "done". Update a single notification row in place across sends with a stable databaseKey, instead of spamming a new row each time.
Typed client with codegen
Generate a fully typed HTTP client for the inbox API from your NestJS controllers using nestjs-codegen — no hand-written fetch calls, no drift between server and client.