Skip to main content

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_TYPEResponsibilityExposes
API_GATEWAYREST API for vendor portal (contacts, campaigns, templates, wallet, auth)HTTP :3000
WEBHOOKReceives and validates Meta webhook callbacks (status updates, inbound messages)HTTP :3000
WORKERBullMQ job consumer — dispatches messages to Meta API, handles retriesNone
OUTBOXAdaptive DB poller — moves OutboxEvent rows from Postgres into Redis queuesNone

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:

EnvironmentCORS_ALLOWED_ORIGINS
Localhttp://localhost:4000
Staginghttps://staging.vendor.kloyst.com
Productionhttps://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) │
└─────────────────────────────────────────────────────────────────┘