The backend API for NotifyKit — notification infrastructure for modern products.
- Framework: NestJS
- Database: PostgreSQL + Prisma ORM
- Cache/Queue: Redis
- Auth: JWT (access + refresh tokens), GitHub OAuth
- Email: SendGrid, Resend, Postmark (provider-agnostic, BYOK with priority fallback)
- Payments: Multi-provider (Paystack for NGN, Polar for USD, with extensible architecture)
- Containerization: Docker
src/
├── main.ts # Entry point — global pipes, filters, interceptors
├── app.module.ts # Root module
├── app.controller.ts # GET /ping, GET /info
│
├── auth/ # Authentication & all guards
│ ├── decorators/
│ │ ├── current-customer.decorator.ts # Extracts customer from request (API key routes)
│ │ ├── ip-rate-limit.decorator.ts # @IpRateLimit(limit, windowSeconds)
│ │ └── public.decorator.ts # @Public() — bypasses global JwtAuthGuard
│ ├── guards/
│ │ ├── api-key.guard.ts # Validates nh_[64hex] API keys
│ │ ├── api-quota.guard.ts # Monthly notification quota enforcement
│ │ ├── customer-rate-limit.guard.ts # Per-customer req/min (API key routes)
│ │ ├── ip-rate-limit.guard.ts # Per-IP req/min (public routes)
│ │ ├── jwt-auth.guard.ts # JWT validation (registered globally)
│ │ ├── roles.guard.ts # Role-based access (registered globally)
│ │ └── user-rate-limit.guard.ts # Per-user req/min (JWT dashboard routes)
│ ├── strategies/
│ │ ├── github.strategy.ts # GitHub OAuth
│ │ └── jwt.strategy.ts # JWT — attaches user + plan to request
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ └── auth.service.ts
│
├── common/
│ ├── constants/
│ │ └── plans.constants.ts # Plan limits — monthly quota, req/min, retention
│ ├── decorators/
│ │ ├── roles.decorator.ts
│ │ └── user.decorator.ts
│ ├── encryption/
│ │ └── encryption.service.ts # AES encryption for stored provider API keys + webhook secrets
│ ├── filters/
│ │ └── all-exceptions.filter.ts # Global exception → { success, error, timestamp }
│ ├── interceptors/
│ │ └── response.interceptor.ts # Wraps responses → { success, data, timestamp }
│ ├── middleware/
│ │ └── activity-logger.middleware.ts
│ ├── rate-limit/
│ │ └── rate-limit.module.ts # Provides IpRateLimitGuard + UserRateLimitGuard
│ ├── slack/
│ │ └── slack.service.ts # Slack Incoming Webhook alerts (SLACK_WEBHOOK_URL)
│ └── utils/
│ ├── enum.util.ts
│ ├── error.util.ts # getErrorMessage / getAxiosErrorData / getAxiosErrorStatus
│ └── response.util.ts
│
├── admin/ # Admin-only endpoints (ADMIN role)
├── billing/ # Plan upgrades, subscriptions, invoices
├── config/ # Cookie, CORS, validation, request-size config
├── platform-email/ # Internal platform email dispatch + HTML templates
├── health/ # GET /health, GET /health/simple
├── notifications/ # Customer-facing API — send email & webhook
├── payment/ # Paystack, Polar (& Stripe scaffold) providers + webhook handlers
├── prisma/ # PrismaService
├── queues/ # BullMQ workers — email & webhook processors
├── redis/ # RedisService (ioredis wrapper + remember helper)
├── email-providers/ # All email provider integrations
│ ├── email-provider.interface.ts # IEmailProvider interface + SendEmailParams
│ ├── email-provider.factory.ts # Resolves provider(s) per customer + plan
│ ├── email-providers.module.ts
│ ├── sendgrid/ # SendGrid send client, domain verification, webhook events + guards
│ ├── resend/ # Resend send client, domain verification, webhook events + guards
│ └── postmark/ # Postmark send client, domain verification, webhook events + guards
└── user/ # Profile, API key, provider keys, jobs history, domain management
- Node.js 18+
- Docker & Docker Compose
- At least one of: SendGrid, Resend, or Postmark account (any combination — used in priority order for the FREE tier)
- A Paystack account (NGN billing) and/or a Polar account (USD billing)
git clone https://github.com/brayzonn/notifykit.git
cd notifykitnpm installcp .env.example .envFill in the required values in .env:
# App
NODE_ENV=development
PORT=3000
# CORS
CORS_ORIGIN=*
ALLOWED_DOMAIN=*
FRONTEND_URL=http://localhost:3001
#Encryption
ENCRYPTION_KEY=
# JWT
JWT_SECRET=your-jwt-secret
JWT_REFRESH_SECRET=your-refresh-secret
JWT_EXPIRES_IN=3600s
# Cookies
COOKIE_SECRET=your-cookie-secret
# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
GITHUB_CALLBACK_URL=http://localhost:3000/api/v1/auth/github/callback
# Paystack (NGN subscriptions)
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxx
PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxx
PAYSTACK_INDIE_PLAN_ID=PLN_xxxxxxxxxxxxx
PAYSTACK_STARTUP_PLAN_ID=PLN_xxxxxxxxxxxxx
PAYSTACK_INDIE_AMOUNT=500000
PAYSTACK_STARTUP_AMOUNT=1000000
# Polar (USD subscriptions)
POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=polar_whs_...
POLAR_SERVER=sandbox
POLAR_INDIE_PRODUCT_ID=
POLAR_STARTUP_PRODUCT_ID=
# Stripe (unused — scaffold only)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_INDIE_PRICE_ID=price_...
STRIPE_STARTUP_PRICE_ID=price_...
# Slack (optional — Incoming Webhook URL for platform email failure alerts)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
# SendGrid (platform shared key — used for Free plan and as fallback)
SENDGRID_API_KEY=SG....
SENDGRID_FROM_EMAIL=
SENDGRID_FROM_NAME=
# Resend (platform shared key — used as fallback when SendGrid is unavailable)
RESEND_API_KEY=re_...
RESEND_FROM_EMAIL=
# Postmark (platform shared key — third-tier fallback for FREE plan)
POSTMARK_API_KEY=
POSTMARK_FROM_EMAIL=
POSTMARK_MESSAGE_STREAM=outbound
# Database
DATABASE_URL=postgresql://notifykit:localdev123@localhost:5432/notifykit
POSTGRES_USER=notifykit
POSTGRES_PASSWORD=localdev123
POSTGRES_DB=notifykit
POSTGRES_PORT=5432
POSTGRES_CONTAINER_NAME=notifykit-postgres
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_CONTAINER_NAME=notifykit-redis
# Rate Limiting
RATE_LIMIT_PER_MINUTE=100
# Admin
ADMIN_EMAIL=your-admin-emaildocker-compose -f docker-compose.dev.yml up -d postgres redisnpx prisma migrate dev
npx prisma generatenpm run start:devThe API will be available at http://localhost:3000/api/v1.
cp .env.example .env.productionFill in production values — use live Paystack/Polar keys, set POLAR_SERVER=production, and use a strong COOKIE_SECRET.
docker-compose -f docker-compose.prod.yml up --build -ddocker-compose -f docker-compose.prod.yml exec api npx prisma migrate deploydocker-compose -f docker-compose.prod.yml logs -f apiPaystack — use ngrok or similar to expose your local server, then set the tunnel URL as the webhook endpoint in your Paystack dashboard.
Polar — use the Polar CLI:
npm install -g @polar-sh/cli
polar login
polar webhooks listen --forward-to localhost:3000/api/v1/payment/polar/webhookThis forwards all Polar webhook events to your local server. Use the sandbox dashboard to trigger test events.
GET /api/v1/health
| Method | Endpoint | Description |
|---|---|---|
| GET | /ping |
Ping |
| GET | /info |
API info |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/signup |
Register new account |
| POST | /api/v1/auth/verify-otp |
Verify email OTP |
| POST | /api/v1/auth/resend-otp |
Resend OTP |
| POST | /api/v1/auth/signin |
Sign in |
| POST | /api/v1/auth/logout |
Logout |
| POST | /api/v1/auth/refresh-token |
Refresh access token |
| GET | /api/v1/auth/github |
GitHub OAuth |
| GET | /api/v1/auth/github/callback |
GitHub OAuth callback |
| POST | /api/v1/auth/reset-password/request |
Request password reset |
| POST | /api/v1/auth/reset-password/confirm |
Confirm password reset |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/user/profile |
Get profile |
| PATCH | /api/v1/user/profile |
Update profile |
| POST | /api/v1/user/change-password |
Change password |
| POST | /api/v1/user/email/change-request |
Request email change |
| GET | /api/v1/user/dashboard |
Get dashboard summary |
| GET | /api/v1/user/api-key |
Get API key |
| POST | /api/v1/user/api-key/generate |
Regenerate API key |
| GET | /api/v1/user/usage |
Get usage stats |
| GET | /api/v1/user/jobs |
List jobs |
| GET | /api/v1/user/jobs/:id |
Get job details |
| POST | /api/v1/user/jobs/:id/retry |
Retry job |
| GET | /api/v1/user/sendgrid-key |
Get SendGrid key status |
| POST | /api/v1/user/sendgrid-key |
Save SendGrid API key |
| DELETE | /api/v1/user/sendgrid-key |
Remove SendGrid API key |
| GET | /api/v1/user/resend-key |
Get Resend key status |
| POST | /api/v1/user/resend-key |
Save Resend API key |
| DELETE | /api/v1/user/resend-key |
Remove Resend API key |
| GET | /api/v1/user/postmark-key |
Get Postmark key status |
| POST | /api/v1/user/postmark-key |
Save Postmark Server Token |
| DELETE | /api/v1/user/postmark-key |
Remove Postmark API key |
| GET | /api/v1/user/sendgrid-webhook-key |
Get SendGrid webhook secret status |
| POST | /api/v1/user/sendgrid-webhook-key |
Save SendGrid webhook secret |
| DELETE | /api/v1/user/sendgrid-webhook-key |
Remove SendGrid webhook secret |
| GET | /api/v1/user/resend-webhook-key |
Get Resend webhook secret status |
| POST | /api/v1/user/resend-webhook-key |
Save Resend webhook secret |
| DELETE | /api/v1/user/resend-webhook-key |
Remove Resend webhook secret |
| GET | /api/v1/user/postmark-webhook-key |
Get Postmark webhook secret status |
| POST | /api/v1/user/postmark-webhook-key |
Save Postmark webhook secret |
| DELETE | /api/v1/user/postmark-webhook-key |
Remove Postmark webhook secret |
| GET | /api/v1/user/email-provider |
List configured providers |
| PATCH | /api/v1/user/email-provider/:provider/priority |
Update provider failover priority |
| POST | /api/v1/user/domain/request |
Request domain verification |
| POST | /api/v1/user/domain/verify |
Verify domain |
| GET | /api/v1/user/domain/status |
Get domain status |
| DELETE | /api/v1/user/domain |
Remove domain |
| POST | /api/v1/user/webhook-secret |
Generate webhook signing secret |
| GET | /api/v1/user/webhook-secret |
Get signing secret status |
| POST | /api/v1/user/webhook-secret/rotate |
Rotate webhook signing secret |
| DELETE | /api/v1/user/webhook-secret |
Delete webhook signing secret |
| DELETE | /api/v1/user/account |
Delete account |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/billing/upgrade |
Upgrade plan |
| POST | /api/v1/billing/cancel |
Cancel subscription |
| GET | /api/v1/billing/subscription |
Get subscription details |
| GET | /api/v1/billing/invoices |
Get invoices |
| GET | /api/v1/payment/methods |
Get payment methods |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/notifications/email |
Send email notification |
| POST | /api/v1/notifications/webhook |
Send webhook notification |
| GET | /api/v1/notifications/jobs |
List notification jobs |
| GET | /api/v1/notifications/jobs/:id |
Get job status |
| POST | /api/v1/notifications/jobs/:id/retry |
Retry failed job |
POST /api/v1/notifications/email accepts two optional fields that override the customer's default priority order for a single message:
| Field | Type | Behavior |
|---|---|---|
provider |
"SENDGRID" | "RESEND" | "POSTMARK" |
Force this email through a specific configured provider. If it fails and fallback is unset, the job fails — no retry. |
fallback |
"SENDGRID" | "RESEND" | "POSTMARK" |
Optional second provider tried only if provider fails. Other configured providers are not tried. |
Validation:
| Condition | Response |
|---|---|
fallback set without provider |
400 Bad Request |
provider equals fallback |
400 Bad Request |
Either field used by a FREE plan customer |
403 Forbidden |
Requested provider or fallback not configured |
400 Bad Request |
Forced routing is a contract: BullMQ does not retry through providers the customer didn't authorize. Routing fields persist with the job, so manual or automatic retries replay the same attempt set.
When neither field is set, behavior is unchanged: the worker tries the customer's full priority list with full failover.
If a customer has configured a webhook signing secret, every outgoing webhook delivery includes two additional headers:
| Header | Value |
|---|---|
X-Webhook-Timestamp |
Unix timestamp (seconds) |
X-Webhook-Signature |
t=<timestamp>,v1=<sha256_hex> |
The signature is computed as HMAC-SHA256(timestamp + "." + JSON.stringify(payload), secret). Customers should verify it at their endpoint by recomputing the signature and comparing using a constant-time comparison. If no secret is configured, these headers are omitted and delivery proceeds unsigned.
Secrets are managed via the /api/v1/user/webhook-secret endpoints (JWT auth). They are stored encrypted at rest using AES-256-CBC. The plaintext secret is returned only at generation time (prefixed whsec_); subsequent calls to GET /webhook-secret return status only.
Every entry in the deliveryLogs[] array on GET /api/v1/notifications/jobs/:id and GET /api/v1/user/jobs/:id now includes a usedProvider field ("SENDGRID" \| "RESEND" \| "POSTMARK" \| null). Success rows record the provider that delivered the email; failure rows record the last provider attempted. Pre-attempt failures and rows that pre-date the migration are null.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/admin/users |
List all users (paginated) |
| GET | /api/v1/admin/users/:id |
Get user details |
| PATCH | /api/v1/admin/users/:id |
Update user |
| DELETE | /api/v1/admin/users/:id |
Soft delete user |
| POST | /api/v1/admin/users/:id/restore |
Restore soft-deleted user |
| DELETE | /api/v1/admin/users/:id/permanent |
Permanently delete user |
| GET | /api/v1/admin/customers |
List all customers (paginated) |
| GET | /api/v1/admin/customers/:id |
Get customer with jobs summary |
| PATCH | /api/v1/admin/customers/:id/plan |
Update customer plan |
| PATCH | /api/v1/admin/customers/:id/usage-reset |
Reset customer usage |
| GET | /api/v1/admin/jobs |
List all jobs (paginated) |
| DELETE | /api/v1/admin/jobs/:id |
Hard delete job |
| GET | /api/v1/admin/domains |
List all sending domains |
| GET | /api/v1/admin/stats |
Get system-wide statistics |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/health |
Full health check |
| GET | /api/v1/health/simple |
Simple health check |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/payment/stripe/webhook |
Stripe webhook handler |
| POST | /api/v1/payment/paystack/webhook |
Paystack webhook handler |
| POST | /api/v1/webhooks/sendgrid |
SendGrid email events (shared platform key, ECDSA) |
| POST | /api/v1/webhooks/sendgrid/:customerId |
SendGrid email events (per-customer key, ECDSA) |
| POST | /api/v1/webhooks/resend/:customerId |
Resend email events (per-customer secret, svix) |
| POST | /api/v1/webhooks/postmark/:customerId |
Postmark email events (per-customer secret, Basic Auth) |
All routes have rate limiting. Two guard types are in use:
IpRateLimitGuard— IP-based. Keyed by{client-ip}:{handler}in Redis. Reads the limit from the@IpRateLimit()decorator on the handler or controller.CustomerRateLimitGuard— identity-based, applied to API key routes (/notifications/*). Keyed by customer ID. Limit is determined by the customer's plan.
All guards use an atomic Redis Lua script (INCR + EXPIRE) with a 60-second window. On Redis failure all guards fail open (allow the request) and log an error.
| Endpoint(s) | Guard | Limit | Key |
|---|---|---|---|
GET /ping, GET /info |
IpRateLimitGuard |
60 req/min | IP |
GET /health |
IpRateLimitGuard |
20 req/min | IP |
GET /health/simple |
IpRateLimitGuard |
30 req/min | IP |
POST /auth/signup |
IpRateLimitGuard |
5 req/min | IP |
POST /auth/signin |
IpRateLimitGuard |
10 req/min | IP |
POST /auth/verify-otp |
IpRateLimitGuard |
5 req/min | IP |
POST /auth/resend-otp |
IpRateLimitGuard |
3 req/min | IP |
POST /auth/refresh-token |
IpRateLimitGuard |
30 req/min | IP |
POST /auth/reset-password/request |
IpRateLimitGuard |
5 req/min | IP |
POST /auth/reset-password/confirm |
IpRateLimitGuard |
10 req/min | IP |
GET /auth/github, GET /auth/github/callback |
IpRateLimitGuard |
20 req/min | IP |
POST /auth/logout |
IpRateLimitGuard |
20 req/min | IP |
POST /payment/stripe/webhook |
IpRateLimitGuard |
300 req/min | IP |
POST /payment/paystack/webhook |
IpRateLimitGuard |
300 req/min | IP |
POST /user/email/verify-new/:token |
IpRateLimitGuard |
20 req/min | IP |
POST /user/email/confirm-old/:token |
IpRateLimitGuard |
20 req/min | IP |
POST /user/email/cancel/:token |
IpRateLimitGuard |
20 req/min | IP |
All /user/* JWT routes |
IpRateLimitGuard |
120 req/min | IP |
All /billing/* routes |
IpRateLimitGuard |
60 req/min | IP |
GET /payment/methods |
IpRateLimitGuard |
60 req/min | IP |
All /admin/* routes |
IpRateLimitGuard |
300 req/min | IP |
All /notifications/* routes |
CustomerRateLimitGuard |
Plan-based | Customer ID |
| Plan | /notifications/* |
|---|---|
| FREE | 5 |
| INDIE | 50 |
| STARTUP | 200 |
Behind a proxy:
IpRateLimitGuardreadsX-Forwarded-For(first hop) before falling back toreq.ip. Ensure your proxy sets this header correctly to avoid all traffic being keyed to a single IP.
This project includes a Jest test suite with 440+ unit tests and 37 e2e scenarios covering:
- Auth & guards: signin, password reset, token refresh, logout, API key validation, quota enforcement, JWT/IP/customer rate limiting
- Email providers: SendGrid, Resend, and Postmark send services + domain verification services + the shared
EmailProviderFactory(FREE-tier fallback order, paid-tier per-customer resolution) - Webhook receivers: SendGrid / Resend / Postmark signature guards (ECDSA, svix, Basic Auth respectively) and event-type mapping for each provider
- Stripe & Paystack webhooks: signature verification, subscription events, payment handling
- Queue processors: email/webhook job processing, multi-provider failover, retry logic
- E2E: complete signup → verify → signin flow, password reset, refresh-token rotation, session limits, OTP resend
Run tests with:
# All unit tests
npm run test
# With coverage
npm run test:cov
# E2E tests
npm run test:e2e
# Watch mode
npm run test:watchSee TEST_SUMMARY.md for detailed test documentation.
MIT © 2026 Eyinda Bright