System Architecture — Kloyst Backend
Kloyst is a multi-tenant WhatsApp Business SaaS platform. The backend (kloyst-core) uses a single deployable image that boots into one of 4 service modes based on the SERVICE_TYPE environment variable.
Service Modes
SERVICE_TYPE | Responsibility | Exposes |
|---|---|---|
API_GATEWAY | REST API for vendor portal (contacts, campaigns, templates, wallet, auth) | HTTP :3000 |
WEBHOOK | Receives and validates Meta webhook callbacks (status updates, inbound messages) | HTTP :3000 |
WORKER | BullMQ job consumer — dispatches messages to Meta API, handles retries | None |
OUTBOX | Adaptive DB poller — moves OutboxEvent rows from Postgres into Redis queues | None |
Request Flow (API Gateway)
Client (vendor portal)
│
│ HTTPS (SSL terminated by Nginx)
│ Authorization: Bearer <access_token>
▼
Nginx (api.staging.kloyst.com/v1/*)
│
│ proxy_pass → 127.0.0.1:3000
▼
NestJS API Gateway (SERVICE_TYPE=API_GATEWAY)
│
├── Global JwtAuthGuard → validates JWT, attaches {sub, vendorId, role}
├── Global LoggingInterceptor → logs request/response to network-*.log
├── Global AllExceptionsFilter → captures errors to exception-*.log
│
├── Controllers → parse DTO, delegate to Services
│
├── Services → Prisma queries (always with vendorId in WHERE)
│
├── Redis → cache, rate-limiting, BullMQ job publication
│
└── PostgreSQL → primary data store
Webhook Flow
Meta API
│
│ POST https://api.staging.kloyst.com/webhook/
▼
Nginx (validates HMAC signature via x-hub-signature-256 header)
│
│ proxy_pass → 127.0.0.1:3002
▼
NestJS Webhook Worker (SERVICE_TYPE=WEBHOOK)
│
├── Signature validation (HMAC-SHA256 of raw body)
├── Event type routing (message_status, inbound_message, etc.)
└── Publishes to OutboxEvent or directly updates MessageLog
Message Dispatch Flow (Worker + Outbox)
Campaign created & approved by vendor
│
API Gateway → inserts OutboxEvent rows (one per recipient with vendorId populated)
│
Outbox Relay (SERVICE_TYPE=OUTBOX)
│ Adaptive poll: CTE window partition query (max 50/vendor, limit 500 total)
│ Ensures fair share and prevents Head-of-Line blocking
└──→ Publishes to BullMQ (Redis) with vendorId lifted to top-level
│
Worker Pool (SERVICE_TYPE=WORKER)
│ Consumes BullMQ jobs with concurrency limits
├── Custom Redis rate-limiter: checks/increments counter for current second
├── If limit exceeded: delays job via moveToDelayed(1s) and yields thread
├── Calls Meta Cloud API → POST /messages
├── Updates MessageLog.status = SENT/FAILED
├── On failure: increments retryCount, sets nextRetryAt (exponential backoff)
└── Permanent failures → DeadLetterQueue table
Contact Import Pipeline
Vendor uploads CSV/XLSX
│
▼
API Gateway
├── MIME validation + size check
├── ClamAV virus scan
├── SHA-256 hash (duplicate import prevention)
├── Creates ContactImportJob (status: UPLOADED)
└── Publishes to BullMQ upload queue
│
Upload Worker
├── Streams file, extracts headers
├── Chunks into batches of 5000 rows → ContactImportChunk
├── Job status: HEADERS_EXTRACTED → AWAITING_MAPPING
│
Vendor maps columns in UI
├── Confirms column mapping
└── Job status: MAPPING_CONFIRMED → QUEUED
│
Chunk Worker (parallelized)
├── libphonenumber E.164 normalization
├── Deduplication: mobileHash @@unique([vendorId, mobileHash])
├── MergeStrategy: SKIP_DUPLICATES | MERGE_LABELS | UPDATE_METADATA | FULL_REPLACE
├── AES encryption of mobile/name fields
├── ContactAuditLog entries for every change
└── Job status: COMPLETED | PARTIAL_SUCCESS | FAILED
Pricing & Quotation Flow
Vendor configures campaign audience
│
API Gateway (QuotationService)
├── Resolves audience to recipient list
├── Fetches PricingSnapshot (immutable rate card)
├── Calculates: recipients × PricingRate × PricingTier discount + PlatformFeeRule
├── Checks Wallet.balance sufficiency
├── Creates Quotation + QuotationItem[] (per country breakdown)
├── Queues PDF generation (PdfStatus: PENDING → GENERATING → READY)
└── Quotation linked to Campaign (campaignId)
│
Campaign execution (Worker)
└── Debits Wallet on send
Module Boundaries
src/
├── common/
│ ├── decorators/ @CurrentUser(), @Public()
│ ├── filters/ AllExceptionsFilter (global exception handler)
│ ├── guards/ JwtAuthGuard (global auth)
│ ├── interceptors/ LoggingInterceptor (global request/response log)
│ └── logger/ Winston config (env-driven rotation)
│
├── modules/
│ ├── auth/ Login, refresh token, email verification
│ ├── user/ User lifecycle, onboarding status
│ ├── vendor/ Tenant creation, settings
│ ├── redis/ Redis wrapper (ioredis)
│ ├── contact/ Contact CRUD + dedup
│ ├── contact-import/ Upload pipeline, chunking, BullMQ jobs
│ ├── campaign/ Campaign builder, scheduling
│ ├── template/ WhatsApp template sync with Meta
│ ├── message/ MessageLog, outbox relay
│ ├── worker/ BullMQ consumers
│ ├── webhook/ Meta webhook handler
│ ├── wallet/ Credit balance management
│ ├── pricing/ Rate cards, quotation engine
│ └── waba/ WABA + PhoneNumber management
│
├── prisma/ PrismaModule + PrismaService
├── main.ts Bootstrap — reads SERVICE_TYPE, API_PREFIX, CORS origins
└── app.module.ts Root module
CORS Configuration (Environment-Driven)
CORS allowed origins are read from CORS_ALLOWED_ORIGINS env var (comma-separated). This means the same image works for local dev, staging, and production without code changes:
| Environment | CORS_ALLOWED_ORIGINS |
|---|---|
| Local | http://localhost:4000 |
| Staging | https://staging.vendor.kloyst.com |
| Production | https://vendor.kloyst.com |
Infrastructure Diagram (Staging)
┌─────────────────────────────────────────────────────────────────┐
│ Server 49.205.216.14 │
│ │
│ Nginx │
│ ├── api.staging.kloyst.com/v1/* → :3000 (api-gateway) │
│ ├── api.staging.kloyst.com/webhook/* → :3002 (webhook) │
│ ├── staging.vendor.kloyst.com → :4001 (front) │
│ ├── logs.staging.kloyst.com → :3003 (grafana) │
│ └── dozzle.staging.kloyst.com → :8888 (dozzle) │
│ │
│ Docker Compose (kloyst-staging-net) │
│ ├── kloyst-staging-api-gateway 127.0.0.1:3000 │
│ ├── kloyst-staging-webhook-worker 127.0.0.1:3002 │
│ ├── kloyst-staging-worker-pool (internal) │
│ ├── kloyst-staging-outbox-relay (internal) │
│ ├── kloyst-staging-postgres (internal, kloyst_staging) │
│ └── kloyst-staging-redis (internal) │
│ │
│ Docker Compose (kloyst-monitoring-net) │
│ ├── kloyst-monitoring-grafana 127.0.0.1:3003 │
│ ├── kloyst-monitoring-loki 127.0.0.1:3100 │
│ ├── kloyst-monitoring-promtail (internal) │
│ └── kloyst-monitoring-dozzle 127.0.0.1:8888 │
│ │
│ Also running (separate): │
│ ├── fynli-api (host network, :4000) │
│ └── Fynli Grafana (:3001) │
└─────────────────────────────────────────────────────────────────┘