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) │
└─────────────────────────────────────────────────────────────────┘