A production-grade Stripe-style payment processing API and dashboard. Dual JWT + API Key authentication, idempotent payment intents, HMAC-signed outbound webhooks with BullMQ-powered retry queues, per-org RBAC, AES-256 encrypted API key secrets, and a full-featured Next.js operator dashboard.
- Project Overview
- Architecture
- Tech Stack
- Database Schema
- Authentication & Authorization
- Idempotency
- Webhook System
- API Reference
- Dashboard (Frontend)
- Environment Variables
- Quick Start
- Full Docker Deployment
- Key Design Decisions
- Project Structure
PayFlow is a Stripe-inspired payment processing platform that lets organizations:
- Create and confirm payment intents (supports INR paise, safe BigInt storage)
- Authenticate programmatically via API keys with granular permission scopes
- Receive payment events at their own endpoints via HMAC-signed webhooks
- Review every action via an immutable audit log
- Manage team members with three-tier RBAC (OWNER / ADMIN / VIEWER)
The platform is multi-tenant: each organization has its own isolated API keys, payment intents, and webhook endpoints. A user can belong to multiple organizations.
payflow/
├── apps/
│ ├── api/ ← NestJS 10 monolith on port 4000
│ └── web/ ← Next.js 15 operator dashboard on port 3000
├── packages/
│ ├── database/ ← Prisma 5 schema (8 models), migrations, seed
│ └── shared/ ← Zod schemas, TS types, validation helpers
├── docker-compose.yml
├── turbo.json
└── pnpm-workspace.yaml
| Service | Port |
|---|---|
| API (NestJS) | 4000 |
| Web dashboard (Next.js) | 3000 |
| PostgreSQL | 5432 |
| Redis (BullMQ) | 6379 |
| Layer | Technology |
|---|---|
| API | NestJS 10, Passport JWT + API Key strategies, Helmet, compression |
| Webhook Queue | BullMQ 5 (Redis-backed), exponential back-off retry, DLQ |
| Database | PostgreSQL 16, Prisma ORM 5 |
| Cache | Redis 7 (BullMQ queues, rate-limit counters) |
| Payments | Stripe Node SDK (test mode), BigInt paise storage |
| Validation | Zod (shared), ZodValidationPipe in API |
| Security | AES-256-GCM API key encryption, HMAC-SHA256 webhook signing |
| Dashboard | Next.js 15 App Router, TailwindCSS, TanStack Query v5, Zustand |
| Monorepo | Turborepo 2, pnpm 9 workspaces |
| Containerisation | Docker 26, Docker Compose |
8 Prisma models:
| Enum | Values |
|---|---|
Plan |
STARTER, GROWTH, ENTERPRISE |
MemberRole |
OWNER, ADMIN, VIEWER |
PaymentIntentStatus |
REQUIRES_PAYMENT_METHOD, REQUIRES_CONFIRMATION, REQUIRES_ACTION, PROCESSING, REQUIRES_CAPTURE, SUCCEEDED, CANCELLED, FAILED |
DeliveryStatus |
PENDING, DELIVERED, FAILED, DEAD_LETTER |
| Model | Key Fields |
|---|---|
User |
id, email, passwordHash, fullName, avatarUrl, emailVerified |
Organization |
id, name, slug (unique), plan, metadata (JSON) |
OrganizationMember |
id, orgId, userId, role — @@unique([orgId, userId]) |
ApiKey |
id, orgId, name, keyHash (SHA-256), keyPrefix (first 8 chars), permissions[], rateLimit, expiresAt, revokedAt |
PaymentIntent |
id, orgId, idempotencyKey, amountPaise (BigInt), currency, status, stripeId, metadata (JSON) — @@unique([orgId, idempotencyKey]) |
WebhookEndpoint |
id, orgId, url, secret (HMAC key), events[], isActive |
WebhookDelivery |
id, endpointId, paymentIntentId, event, payload (JSON), status, attempt, nextRetryAt |
AuditLog |
id, orgId, userId, action, resourceType, resourceId, metadata (JSON), ipAddress |
PayFlow supports two authentication methods on every protected route:
- Obtained via
POST /api/v1/auth/login Authorization: Bearer <token>- Contains
userId,orgId,role,email - Default expiry: 7 days (configurable via
JWT_EXPIRES_IN)
- Created via the dashboard (OWNER or ADMIN only)
X-API-Key: pf_live_<random>header- Stored as SHA-256 hash in the database — the plaintext is shown only once at creation
- Each key has a
permissionsarray:["payments:read", "payments:write", "webhooks:read", "webhooks:write"] - Rate-limited per key (default: 1000 requests per minute, tracked in Redis)
| Role | Can create/delete API keys | Can manage webhook endpoints | Can view audit log | Can manage members |
|---|---|---|---|---|
| OWNER | ✅ | ✅ | ✅ | ✅ |
| ADMIN | ✅ | ✅ | ✅ | ❌ |
| VIEWER | ❌ | ❌ | ✅ | ❌ |
JwtOrApiKeyGuard → validates token OR API key, sets req.user
ApiKeyGuard → checks permission scopes on API-key-authenticated requests
RolesGuard → enforces minimum role requirement from @Roles() decorator
Creating a payment intent requires an Idempotency-Key header (UUID format).
- The key is scoped per organization:
@@unique([orgId, idempotencyKey])in the DB - If a request arrives with the same key and same body, the original response is returned immediately
- If the key matches but the body differs, a
409 Conflictis returned - Keys are validated with a strict UUID regex before any DB lookup
POST /api/v1/payments
Authorization: Bearer <token>
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"amountPaise": 49900,
"currency": "INR",
"description": "Pro plan subscription"
}When a payment changes status, PayFlow delivers a signed HTTP POST to all active endpoints registered for that event type.
Payment status changes
│
▼
WebhooksService.dispatchEvent()
│
▼
BullMQ queue: "webhook-deliveries"
│
▼
WebhookWorker processes job:
1. Build payload: { id, event, createdAt, data }
2. Sign with HMAC-SHA256: X-PayFlow-Signature: sha256=<hex>
3. POST to endpoint URL (5s timeout)
4. On success → DeliveryStatus.DELIVERED
5. On failure → schedule retry with exponential back-off
│
▼ (after max retries)
DeliveryStatus.DEAD_LETTER
| Attempt | Delay |
|---|---|
| 1st | 30 s |
| 2nd | 2 min |
| 3rd | 10 min |
| 4th | 1 hour |
| 5th | 24 hours |
import crypto from "crypto";
function verifySignature(
rawBody: string,
signature: string,
secret: string,
): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}| Event | Triggered when |
|---|---|
payment_intent.created |
A new payment intent is created |
payment_intent.succeeded |
Payment confirmed and captured |
payment_intent.failed |
Payment processing failed |
payment_intent.cancelled |
Payment intent cancelled |
payment_intent.requires_action |
3DS or additional action required |
Base URL: http://localhost:4000/api
Swagger UI: http://localhost:4000/api/docs
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/auth/register |
Public | Register user + create organization |
| POST | /v1/auth/login |
Public | Login → { access_token, user, org } |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/organizations/me |
JWT | Get current user's organization |
| PATCH | /v1/organizations/me |
JWT (OWNER) | Update organization name/plan |
| GET | /v1/organizations/me/members |
JWT | List organization members |
| POST | /v1/organizations/me/members |
JWT (OWNER) | Invite a member by email |
| PATCH | /v1/organizations/me/members/:id |
JWT (OWNER) | Update member role |
| DELETE | /v1/organizations/me/members/:id |
JWT (OWNER) | Remove a member |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/payments |
JWT / API Key | Create payment intent (idempotent) |
| GET | /v1/payments |
JWT / API Key | List payment intents (paginated, filter) |
| GET | /v1/payments/:id |
JWT / API Key | Get payment intent detail |
| POST | /v1/payments/:id/confirm |
JWT / API Key | Confirm a payment intent |
| POST | /v1/payments/:id/capture |
JWT / API Key | Capture an authorized payment |
| POST | /v1/payments/:id/cancel |
JWT / API Key | Cancel a payment intent |
List filter query params: status, from (ISO date), to (ISO date), page, pageSize (max 100)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/api-keys |
JWT (ADMIN+) | Create API key — secret shown once |
| GET | /v1/api-keys |
JWT | List API keys (no secrets returned) |
| DELETE | /v1/api-keys/:id |
JWT (ADMIN+) | Revoke an API key |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/webhooks/endpoints |
JWT / API Key | Create webhook endpoint |
| GET | /v1/webhooks/endpoints |
JWT / API Key | List webhook endpoints |
| GET | /v1/webhooks/endpoints/:id |
JWT / API Key | Get endpoint details |
| PATCH | /v1/webhooks/endpoints/:id |
JWT / API Key | Update endpoint URL / events |
| DELETE | /v1/webhooks/endpoints/:id |
JWT / API Key | Delete endpoint |
| GET | /v1/webhooks/deliveries |
JWT / API Key | List delivery attempts |
| POST | /v1/webhooks/deliveries/:id/retry |
JWT / API Key | Manually retry a failed delivery |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/audit |
JWT | Paginated audit log (filter: action, resourceType, resourceId) |
Query params: resourceType, resourceId, action, page, pageSize (max 100)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
Public | { status: "ok", timestamp } |
Built with Next.js 15 App Router, TailwindCSS, TanStack Query v5, Zustand.
| Route | Description |
|---|---|
/auth/login |
Login form |
/auth/register |
Registration form — creates user + organization |
/dashboard |
Overview — total volume, successful payments, failed, active keys |
/payments |
Payment intent list with status badges + filters |
/payments/[id] |
Payment detail — timeline, metadata, webhook deliveries |
/api-keys |
API key management — create, view prefix, revoke |
/webhooks |
Webhook endpoint management + delivery history |
/webhooks/[id] |
Endpoint detail — delivery log with retry button |
/settings |
Organization settings + member management (invite/role/remove) |
/audit-log |
Full immutable audit trail with filters |
- Total payment volume (last 30 days)
- Success rate %
- Active API keys count
- Webhook delivery success rate
- Recent payment intent list
- Volume chart (daily breakdown)
Copy .env.example to .env:
# Database
DATABASE_URL=postgresql://payflow:payflow_dev@localhost:5432/payflow_dev
# Redis
REDIS_URL=redis://localhost:6379
# JWT
JWT_SECRET=change-me-in-production-min-32-chars
JWT_EXPIRES_IN=7d
# API URLs
API_PORT=4000
API_URL=http://localhost:4000
WEB_URL=http://localhost:3000
# Stripe (test mode)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Webhook delivery signing
WEBHOOK_SIGNING_SECRET=change-me-webhook-signing-secret
WEBHOOK_MAX_RETRIES=5
WEBHOOK_RETRY_DELAYS=[30,120,600,3600,86400]
# Rate limiting
RATE_LIMIT_DEFAULT_RPM=1000
RATE_LIMIT_WINDOW_SECONDS=60
# AES-256 encryption key for API key secrets
ENCRYPTION_KEY=change-me-32-char-encryption-key!!
# Next.js public
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXTAUTH_SECRET=change-me-nextauth-secret
NEXTAUTH_URL=http://localhost:3000- Docker & Docker Compose v2+
- Node.js 20+ and pnpm 9+
docker compose up postgres redis -d
docker compose ps # wait for "healthy" statuspnpm installpnpm --filter @payflow/database db:migrate
pnpm --filter @payflow/database db:seedThe seed creates:
- Organization: "Acme Corp"
- Owner account:
[email protected]/password123 - Admin account:
[email protected]/password123 - Viewer account:
[email protected]/password123 - One test API key (shown in terminal output)
- One webhook endpoint pointing to
https://webhook.site/<uuid> - 20 sample payment intents in various states
pnpm dev- API: http://localhost:4000
- Swagger UI: http://localhost:4000/api/docs
- Dashboard: http://localhost:3000
# 1. Configure environment
cp .env.example .env
# Edit .env — set JWT_SECRET, ENCRYPTION_KEY, STRIPE_SECRET_KEY, WEBHOOK_SIGNING_SECRET
# 2. Build and start everything
docker compose up -d --build
# 3. Run migrations
docker compose exec api npx prisma migrate deploy
docker compose exec api npx prisma db seedamountPaise is stored as BigInt — never Float or Decimal. This eliminates all floating-point rounding errors. ₹499.00 = 49900 paise. The Prisma BigInt maps to JS BigInt; all serialization to JSON uses .toString().
- The plaintext key
pf_live_<48-char-random>is shown once at creation and never stored - Stored value:
SHA-256(plaintext)— no way to recover the key from the database keyPrefix(first 8 chars) is stored for display in the UI- Keys are encrypted at rest using AES-256-GCM via the
ENCRYPTION_KEYenv var
The unique constraint @@unique([orgId, idempotencyKey]) means two different organizations can use the same idempotency key without conflict. The key is validated as a UUID before any database operation.
Outbound webhooks include X-PayFlow-Signature: sha256=<hex> computed from HMAC-SHA256(rawBody, endpointSecret). Consumers can verify authenticity without shared-secret databases. The signing secret is generated at endpoint creation and shown once.
Webhook jobs are persisted in Redis. If the API process crashes mid-delivery, the job remains in the queue and is retried on restart. The exponential back-off schedule (30s → 2m → 10m → 1h → 24h) mirrors Stripe's delivery behaviour. After all retries, the delivery moves to DEAD_LETTER status.
AuditLog records are insert-only — no UPDATE or DELETE operations are allowed on the table. Every state-changing action (create payment, revoke key, change role, etc.) inserts a new record with the actor's userId, orgId, IP address, and full metadata diff.
Each API key is assigned a rateLimit value (default: 1000 requests/minute). On every request authenticated via API key, the following sliding-window check runs in Redis:
ZADD payflow:rl:{keyId} {now_ms} {now_ms}
ZREMRANGEBYSCORE payflow:rl:{keyId} 0 {now_ms - 60000}
ZCARD payflow:rl:{keyId}
EXPIRE payflow:rl:{keyId} 61
If ZCARD exceeds the key's rateLimit, the request is rejected with 429 Too Many Requests. The sliding window (as opposed to fixed-window) prevents the "double burst at window boundary" problem — at any point in time the count reflects exactly the last 60 seconds of traffic.
Rather than NestJS's built-in class-validator ValidationPipe, PayFlow uses a custom ZodValidationPipe that accepts a Zod schema. This keeps validation logic co-located with the shared DTO types in packages/shared and provides richer, more composable validation.
payflow/
├── apps/
│ ├── api/
│ │ └── src/
│ │ ├── auth/ ← JWT + API Key strategies, register/login
│ │ │ ├── guards/ ← JwtAuthGuard, ApiKeyGuard, JwtOrApiKeyGuard, RolesGuard
│ │ │ └── decorators/ ← @CurrentUser(), @Roles(), @RequirePermissions()
│ │ ├── organizations/ ← Org CRUD + member management
│ │ ├── payments/ ← Payment intent lifecycle + Stripe integration
│ │ ├── api-keys/ ← Key creation, hashing, rate-limit tracking
│ │ ├── webhooks/ ← Endpoint management + BullMQ delivery worker
│ │ ├── audit/ ← Append-only audit log
│ │ ├── stripe/ ← Stripe SDK wrapper module
│ │ ├── redis/ ← ioredis global module
│ │ ├── database/ ← PrismaClient global module
│ │ ├── common/
│ │ │ └── pipes/ ← ZodValidationPipe
│ │ ├── config/ ← @nestjs/config typed config
│ │ ├── health/ ← GET /health
│ │ ├── app.module.ts
│ │ └── main.ts ← Helmet, CORS, Swagger, global prefix /api
│ └── web/
│ └── app/
│ ├── (auth)/ ← /auth/login, /auth/register
│ └── (dashboard)/ ← /dashboard, /payments, /api-keys, /webhooks,
│ /settings, /audit-log
├── packages/
│ ├── database/
│ │ └── prisma/
│ │ ├── schema.prisma ← 8 models
│ │ └── seed.ts
│ └── shared/
│ └── src/
│ ├── schemas/ ← Zod schemas for every DTO
│ ├── types/ ← TypeScript interfaces
│ └── index.ts
├── docker-compose.yml
├── turbo.json
├── pnpm-workspace.yaml
└── .env.example