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. 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.
AWS SES (email)
SES delivers bounce / complaint / delivery events via SNS. The controller handles the SNS subscription handshake and the notifications:
import { createSesNotificationController } from '@dudousxd/nestjs-notifications-delivery-tracking';
const SesController = createSesNotificationController({
path: 'webhooks/ses',
autoConfirmSubscription: true, // GET the SubscribeURL on first connect
});SNS message-signature verification is off by default (it pulls no heavy dependency). For internet-facing endpoints, terminate the webhook behind a verified API gateway, or implement SNS signature verification before trusting the payload.
Correlating sends to callbacks
A webhook says "message SM123 was delivered" — the tracker needs to know which record that is. When
a channel transport returns the provider's id (Twilio's MessageSid, SES's messageId), the listener
captures it automatically, so delivered/bounced updates just work.
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.