A production-grade backend service that receives and processes Paystack payment webhooks, stores transaction records, sends email notifications, and exposes a JWT-protected dashboard with an SSR UI.
- Runtime: Node.js with TypeScript
- Framework: Fastify
- Database: PostgreSQL 18
- ORM: Drizzle ORM
- Auth: JWT with refresh token rotation
- Email: Resend
- Validation: Zod
- Infrastructure: Docker + Docker Compose
- Paystack webhook ingestion with HMAC-SHA512 signature verification
- Payment initialization and verification via Paystack API
- Idempotent transaction storage — duplicate webhooks are safely ignored
- Email notifications on successful payments via Resend
- Notification state tracking — every notification is recorded as pending before sending, then updated to sent or failed
- JWT authentication with short-lived access tokens (15min) and rotating refresh tokens (7 days)
- Refresh token hashed before storage — protects against database compromise
- Stolen token detection — replayed refresh tokens immediately invalidate the session
- Protected dashboard route with pagination
- SSR dashboard UI built with EJS
- Input validation with Zod on all routes
- Structured logging with Pino on all controllers
- CORS and rate limiting on auth routes
- Health check endpoint for deployment monitoring
- Fully Dockerized with health checks and persistent volumes
src/
routes/ — URL registration
controllers/ — HTTP request/response handling
services/ — Business logic
middleware/ — JWT authentication
validators/ — Zod input validation schemas
templates/ — Email HTML templates
config/ — Centralised configuration
db/ — Drizzle schema and connection
types/ — TypeScript type declarations
views/
login.ejs — Admin login page
dashboard.ejs — Transactions dashboard
Client / Paystack
│
▼
POST /api/webhook
│
├── Verify HMAC-SHA512 signature
│ │ invalid → 401
│
├── Filter charge events (charge.success / charge.failed / charge.pending)
│ │ other events → 200 (ignored)
│
├── Insert transaction record (idempotent — duplicate references ignored)
│
└── On charge.success:
│
├── Insert notification record (status: pending)
├── Send email via Resend
└── Update notification status → sent | failed
The dashboard is a separate authenticated surface that reads the same transaction and notification records written by the webhook flow.
- Docker Desktop
- Node.js 22+
- pnpm
Copy .env.example to .env and fill in the values:
cp .env.example .envRequired variables:
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
DATABASE_URL=
PAYSTACK_SECRET_KEY=
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
NODE_ENV=
RESEND_API_KEY=
TEST_EMAIL=
ALLOWED_ORIGIN=
Generate JWT secrets with:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Start the database:
pnpm docker:upRun migrations:
pnpm db:migrateStart the dev server:
pnpm devServer runs on http://localhost:3000
pnpm docker:up:buildRegistration is intentionally restricted to the API. Create your admin account once:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "admin@yourdomain.com", "password": "yourpassword"}'Then visit http://localhost:3000/login to access the dashboard.
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| POST | /api/auth/register |
Register admin user | No |
| POST | /api/auth/login |
Login and receive tokens | No |
| POST | /api/auth/refresh |
Rotate refresh token | No |
| POST | /api/auth/logout |
Logout and clear session | Yes |
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| POST | /api/webhook |
Receive Paystack webhook events | No (HMAC verified) |
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| POST | /api/payments/initialize |
Initialize a Paystack payment | No |
| GET | /api/payments/verify/:reference |
Verify a payment by reference | No |
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| GET | /api/dashboard/transactions |
Get paginated transactions | Yes |
| Method | Endpoint | Description |
|---|---|---|
| GET | /login |
Admin login page |
| GET | /dashboard |
SSR transactions dashboard |
| GET | /logout |
Clear session and redirect to login |
| Method | Endpoint | Description |
|---|---|---|
| GET | /health |
Server and database health check |
GET /api/dashboard/transactions?page=1&limit=10
- Install ngrok and run
ngrok http 3000 - Copy the ngrok URL into your Paystack dashboard under Settings → API Keys & Webhooks → Test Webhook URL
- Create a payment page on Paystack and complete a test payment
- Confirm the transaction appears in your database and a notification email is sent
Test card details:
Card number: 4084 0840 8408 4081
Expiry: 01/99
CVV: 408
PIN: 0000
OTP: 123456
curl -X POST http://localhost:3000/api/payments/initialize \
-H "Content-Type: application/json" \
-d '{"email": "customer@example.com", "amount": 50000}'Returns an authorization_url — open it in the browser to complete the payment.
curl http://localhost:3000/api/payments/verify/YOUR_REFERENCERegister:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password123"}'Login:
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password123"}'Get transactions:
curl -X GET "http://localhost:3000/api/dashboard/transactions?page=1&limit=10" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Refresh token:
curl -X POST http://localhost:3000/api/auth/refresh \
--cookie "refreshToken=YOUR_REFRESH_TOKEN"Logout:
curl -X POST http://localhost:3000/api/auth/logout \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"curl http://localhost:3000/healthpnpm db:generate # generate migrations from schema changes
pnpm db:migrate # apply migrations to database
pnpm db:studio # open Drizzle Studio in browserpnpm docker:up # start containers
pnpm docker:up:build # start containers and rebuild images
pnpm docker:down # stop containers
pnpm docker:down:v # stop containers and wipe volumes- HMAC-SHA512 — Paystack webhook signature verified with
crypto.timingSafeEqualto prevent timing attacks - Idempotency — unique constraint on
paystack_referenceat the database level prevents duplicate transaction records - Refresh token hashing — refresh tokens stored as HMAC-SHA256 hashes keyed to the server secret, raw tokens never persisted
- Token rotation — each refresh burns the old token and issues a new one
- Stolen token detection — replayed refresh tokens immediately null the session and force re-login
- httpOnly cookies — refresh tokens stored in httpOnly cookies, inaccessible to JavaScript
- Rate limiting — auth routes limited to 5 requests per minute to prevent brute force
- CORS — restricted to configured allowed origins
- Input validation — all routes validated with Zod before touching business logic
- Message queue (BullMQ / RabbitMQ) for async notification dispatch and retry with exponential backoff
- Unit, integration, and load tests
- Graceful shutdown with in-flight request draining
- Dead letter queue for persistently failed notifications
- Amounts are stored in kobo (Paystack's smallest currency unit). Divide by 100 to get naira.
- Email notifications in development mode are sent to
TEST_EMAIL. Verify a domain on Resend for production sends. - Admin registration is API-only by design — the dashboard is not publicly accessible.