nestjs-notifications
Recipes

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-tracking

The lifecycle

Each delivery is one record with a status:

StatusMeaning
queuedAccepted for delivery, not yet handed to the transport.
sentThe channel transport accepted it (no terminal confirmation yet).
failedThe transport threw while sending.
deliveredThe provider confirmed delivery, via an inbound status webhook.
bouncedThe 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):

app.module.ts
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:

webhooks.module.ts
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:

  1. Topic — TopicArn must be in topicArns. 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.
  2. Freshness — Timestamp must be within maxMessageAgeMs (default one hour; SNS keeps the original timestamp across retries, so keep it above your retry window). false turns it off.
  3. Certificate URL — SigningCertURL must be https://sns.<region>.amazonaws.com/…pem (…amazonaws.com.cn in 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.
  4. Signature — the canonical string-to-sign is rebuilt for the message type and checked with RSA: SignatureVersion 1 is SHA1withRSA, 2 is 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:

app.module.ts
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.

On this page