HTTP gateway for Telegram. Runs Telegram accounts as instances and exposes them through a clean REST API, a realtime (SSE) stream and signed outbound webhooks. Built on NestJS 11 + Prisma 7 (PostgreSQL) + Redis, with a Vue 3 dashboard to manage everything visually.

- What is Flux

- How the app works (end to end)

- Architecture

- Engines (Telegram layer)

- Event system

- Webhooks

- Authentication & security

- Permissions & access

- Data model

- API types & contracts

- Endpoints

- Dashboard (Vue SPA)

- Stack

- Setup

- Development

- Folder structure

- Environment variables

- Deployment

- Roadmap

- Support

Flux connects one or more Telegram accounts (over MTProto) and turns each one into an instance manageable over HTTP. With it you can:

- Connect accounts via QR code or phone (OTP) + 2FA, with the session persisted and automatic reconnection.

- Read chats and history, send messages and media, and download avatars/attachments.

- Receive realtime events (new/edited/deleted messages, read receipts, reactions, session status) over SSE and over durable, signed webhooks.

- Operate everything from a dashboard or directly through the API (with OpenAPI/Scalar at /docs).

The path of a message, from Telegram to your system:

- Connection — The TelegramManagerresolves the instance's engine (e.g. GramJS), connects using the session saved in Redis and, if needed, drives the QR/2FA login.

- Capture — The engine subscribes to Telegram updates and normalizes them into an engine-agnostic NormalizedEvent, delivered viaonEvent.

- Sync — The TelegramSyncServicepersists new/edited messages in Postgres and publishes aDomainEventon the bus. TheTelegramManagerpublishessession.statuson lifecycle transitions.

- Fan-out — The TelegramEventBus(RxJS) distributes the event to two consumers: the SSE stream (delivery to the dashboard/client) and the WebhookDispatcher.

- Durable delivery — The dispatcher creates a WebhookDeliveryrow (outbox) per matching webhook (linked instance ∩ subscribed type ∩ active). A worker drains the queue, signs the body with HMAC and POSTs it, with retry/backoff and a persisted log.

The code separates core (reusable domain/infra) from modules (HTTP surface).

src/

├── core/ # domain + infrastructure (no HTTP route)

│ ├── prisma/ # schema, migrations, PrismaService

│ ├── redis/ # Redis client (sessions)

│ ├── telegram/ # engines, manager, sync, event bus, views

│ └── webhooks/ # service, dispatcher, worker, signing

└── modules/ # controllers + DTOs + entities (OpenAPI)

├── auth/ # login, JWT, API key

├── users/ # dashboard users

├── telegram/ # instances, chats, messages, media, SSE

├── webhooks/ # webhook CRUD, links, deliveries

├── health/ # healthchecks (Terminus)

└── dashboard/ # redirect / → /dashboard

Principles:

- Core knows nothing about HTTP. Controllers in modulesinject services fromcore.

- In-process pub/sub. Events travel over an RxJS Subject(TelegramEventBus) — Redis is used only for sessions; no external queue (BullMQ) is required.

- Postgres outbox. Webhook durability comes from the WebhookDeliverytable (queue + audit log), drained by an interval worker.

- Typed boundary. Telegram int64 ids (BigInt) become strings; dates are ISO-8601. Views (*View) are the shapes exposed to the client; Prisma models never leak secrets.

An engine is a pluggable adapter that knows how to connect and operate an account on a specific Telegram library. The TelegramManager stays agnostic and delegates to the engine resolved by the instance's engine field.

interface InstanceEngine {

readonly key: EngineKey; // 'gramjs' | 'telegraf'

readonly capabilities: EngineCapabilities;

isAvailable(): boolean; // engine implemented and usable

requiredConfig(): string[]; // required config keys

connect(session: string, config: EngineConfig): Promise<EngineClient>;

}

interface EngineCapabilities {

qrLogin: boolean; // QR login over MTProto (user accounts)

botToken: boolean; // bot-token login (Bot API)

messaging: boolean; // list dialogs / read history / send / receive updates

}The EngineClient is the live handle of a connection: isAuthorized, disconnect, getMe, saveSession, and — when the capability exists — qrLogin, listDialogs, getHistory, sendMessage, sendMedia, downloadAvatar, downloadMessageMedia and the onEvent(handler) that delivers normalized events and returns an unsubscribe function.

The default engine is

gramjs. Adding a new engine = implementInstanceEngineand register it in theTELEGRAM_ENGINESprovider — nothing in the manager has to change.

Each engine converts native types into engine-agnostic shapes: NormalizedChat, NormalizedContact, NormalizedMessage, NormalizedMedia, NormalizedReaction and the discriminated NormalizedEvent. This ensures sync, SSE and webhooks behave identically regardless of the engine.

Instances emit normalized events, distributed in-process by the TelegramEventBus. A DomainEvent has the shape:

interface DomainEvent {

instanceId: string;

type: EventType;

at: string; // ISO timestamp

payload: Record<string, unknown>;

}In

message.read,direction: 'outbound'= the recipient read your message (the classic "seen");'inbound'= you read their messages.

There are two ways to consume events: SSE (GET /telegram/instances/:id/messages/stream, focused on message.new) and webhooks (any subset of types, durable delivery).

A webhook subscribes to a subset of event types and is linked to one or more instances (an M2M relationship). When an event matches (linked instance ∩ subscribed type ∩ active webhook), a delivery is queued and POSTed.

- Durable — each attempt is a WebhookDeliveryrow in Postgres (survives restarts).

- Retry with backoff — 10s → 1m → 5m → 30m → 2h; after 6 attempts the delivery becomesdead.

- Signed — body signed with HMAC-SHA256; verify before trusting.

- Auditable — status, HTTP code, attempt count, last error and the target's response body are queryable (GET /webhooks/:id/deliveries), with manual resend.

By default a webhook may only target a public address — an SSRF guard blocks private, loopback and reserved ranges (validated at create/update and re-checked at delivery to defeat DNS rebinding). Set allowInternal: true to deliver to a private/loopback target instead, e.g. another service on the same Docker network or LAN:

// e.g. an n8n instance reachable as http://n8n:5678 on the same compose network

{ "name": "n8n", "url": "http://n8n:5678/webhook/flux", "events": ["message.new"], "allowInternal": true }In the dashboard this is the External (internet) vs Internal (local/Docker network) choice on the webhook form. Cloud-metadata / link-local addresses (169.254.0.0/16, fe80::/10) stay blocked regardless of this flag, since those are never a legitimate destination.

{

"event": "message.new",

"instanceId": "ckinst0001",

"at": "2026-06-19T12:00:00.000Z",

"data": { "...": "event payload (e.g. MessageView)" }

}The secret (prefix whsec_) is returned only once when creating/rotating the webhook. Sign the raw body and compare in constant time:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: string, header: string, secret: string): boolean {

const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;

const a = Buffer.from(expected);

const b = Buffer.from(header);

return a.length === b.length && timingSafeEqual(a, b);

}Two layers protect the API:

- JWT — identifies the dashboard user. Obtained from POST /auth/login(httpOnlycookie and bearer).GET /auth/meaccepts a JWT without requiring the API key (@NoApiKey()).

- API key — x-api-keyheader, the gateway's static key required on most routes (auth and health are exempt).

Additional protections:

- Passwords: Argon2id hashing (never plaintext).

- Telegram api_hash: encrypted at rest (AES-256-GCM), never returned.

- Rate limiting: global per-IP throttling (@nestjs/throttler;@SkipThrottle()where appropriate).

- Helmet: strict CSP on the API; relaxed CSP only on /docs(Scalar) and/dashboard(SPA).

- CORS: whitelisted origins via CORS_ORIGIN.

- Webhook SSRF guard: outbound webhook targets are restricted to public addresses by default (private/loopback/reserved ranges blocked at create and delivery time); private targets require explicit allowInternal, and cloud-metadata / link-local addresses stay blocked unconditionally.

- Safe BigInt: int64 ids serialized as strings (global shim in main.ts).

Authorization is global per user: a single dashboard role applies to all instances. There are no per-instance roles.

- viewer— read-only in the dashboard.

- operator— operates instances, sends messages and manages webhooks.

- admin— everything above + manages users and roles. The seeded user (- SEED_*) is promoted to admin on boot.

Enforcement: instance routes use @RequireInstancePermission(...) + InstanceAccessGuard (resolves permissions from the global role via AccessService); user routes use @Roles('admin') + RolesGuard. Instance GET responses include myRole (the requester's global role) so the UI can hide disallowed actions.

Prisma 7 + PostgreSQL. Cascades from User / Instance / Webhook.

User ─┬─ instances[] (Telegram accounts created by the user)

└─ webhooks[] (the user's webhooks)

id, email, username, role(Role: admin|operator|viewer), createdAt

Setting key (PK) → telegram.apiId, telegram.apiHash (encrypted)

Instance ─┬─ chats[]

├─ contacts[]

├─ messages[]

└─ webhookLinks[] (M2M with Webhook)

id, ownerId, label, engine, status, apiId?, apiHashEnc?, tgUserId?, username?, phone?, createdAt

enum Role { admin operator viewer }

Chat id, instanceId, tgPeerId, type(user|group|channel), title?, username?, lastMessageAt?

Contact id, instanceId, tgUserId, firstName?, lastName?, username?, phone?, isContact

Message id, instanceId, chatId, tgMessageId, senderId?, outgoing, text?, media*, date, editedAt?, replyToTgId?

Webhook ─┬─ instanceLinks[] (M2M with Instance)

└─ deliveries[]

id, ownerId, name, url, secret, active, events String[], createdAt, updatedAt

WebhookInstance @@id([webhookId, instanceId]) (M2M join)

WebhookDelivery id, webhookId, instanceId?, event, status(WebhookStatus),

attempts, statusCode?, lastError?, payload(Json),

nextAttemptAt, createdAt, deliveredAt?

@@index([status, nextAttemptAt])

enum WebhookStatus { pending success failed dead }

The shapes exposed to the client (ISO dates, int64 as string). All have a full schema at /docs.

// Telegram

interface InstanceView { id; label; engine; status; firstName?; username?; phone?; apiId?; createdAt }

interface ChatView { id; tgPeerId; type; title?; username?; hasPhoto; lastMessageAt? }

interface MessageView { id; chatId; tgMessageId; text?; outgoing; date; senderId?; sender?; media? }

interface MediaView { type; mimeType?; fileName?; width?; height?; duration? }

type InstanceStatus = 'new'|'connecting'|'awaiting_qr'|'awaiting_code'|'password_required'|'authorized'|'disconnected'|'error'

// Auth & access

interface UserEntity { id; email; username; role: 'admin'|'operator'|'viewer' } // never exposes the hash

interface LoginResponse { accessToken } // the JWT also goes in the httpOnly cookie

// InstanceView gains `myRole?: 'admin'|'operator'|'viewer'` (the requester's global role)

// Webhooks

interface WebhookView { id; name; url; active; allowInternal; events[]; instanceIds[]; createdAt; updatedAt }

interface WebhookWithSecret extends WebhookView { secret } // only on create / regenerate-secret

interface WebhookDeliveryView { id; webhookId; instanceId?; event; status; attempts; statusCode?; lastError?; responseBody?; nextAttemptAt; createdAt; deliveredAt? }Most routes require JWT (Bearer) +

x-api-key.authandhealthhave exceptions (see the Auth column). Interactive documentation at/docs.

Useful bodies

- POST /webhooks—{ name, url, events[], instanceIds?, allowInternal? }

- PATCH /webhooks/:id—{ name?, url?, active?, events?, allowInternal? }

Vue 3 + TypeScript + Tailwind, served at /dashboard.

- Overview — uptime, instance count and health, total webhooks.

- Instances — create, connect via QR or phone, start/stop, details, open chats.

- Chats — list dialogs, read paginated history, send text and media, realtime.

- Webhooks — create/edit (events + instances, external/internal target), enable/disable, view deliveries (status/code/attempts, with error + response detail) and resend, rotate the secret.

- Users — list accounts and, as an admin, create, edit (email/username/password/role) and delete users.

- Settings — set api_id/api_hash, testx-api-key.

- Help — step-by-step guide. i18n: English + Portuguese (BR).

- Node.js 22+

- Docker + Docker Compose (Postgres + Redis)

- Git

git clone https://github.com/PedroL3m0z/Flux-Api.git

cd flux-api

# Optional: the app boots without a .env. Copy it only to override something.

cp .env.example .env

yarn install

yarn prisma:generateMinimal configuration (zero-config). With no variables defined, the app derives

DATABASE_URLfrom thedocker-compose.dev.ymlPostgres and generates strong secrets (JWT_SECRET,API_KEY,TELEGRAM_SESSION_SECRET) on first boot, saving them to./data/secrets.json(DATA_DIR). The generatedAPI_KEYis printed to the log only once — keep it. Set any variable in.envto override the automatic values.

docker-compose.yml runs the single Flux image, which bundles the API,

PostgreSQL and Redis in one container:

docker compose up -d

# API: http://localhost:3000

# Dashboard: http://localhost:3000/dashboard

# Docs: http://localhost:3000/docsMigrations run automatically. The image applies

prisma migrate deploybefore the API starts. You only apply migrations by hand when running the app on the host (see below).

The same published image, pulled from either registry:

# Docker Hub

docker run -d -p 3000:3000 -v flux_data:/data pedrooaj/flux-api

# or GitHub Container Registry

docker run -d -p 3000:3000 -v flux_data:/data ghcr.io/pedrol3m0z/flux-apiThe image is built for linux/amd64 and tagged per release

(X.Y.Z, X.Y, latest). The /data volume persists the database, Redis

data and the auto-generated secrets — keep it across container recreations.

On first boot the app generates the admin login and API_KEY and prints them

to the log once (docker logs <container>): grab them immediately.

Common overrides (-e VAR=value):

One container, no service isolation: a restart cycles every process. For a single host / self-hosting this is exactly what you want; mount

/dataand you are done.

For development you usually run the app on the host against Postgres + Redis in

Docker (docker-compose.dev.yml ships just those two):

docker compose -f docker-compose.dev.yml up -d

yarn prisma migrate dev --schema=src/core/prisma/schema.prisma

yarn start:devyarn build:all # backend + frontend

yarn prisma:deploy # apply migrations (no entrypoint here)

node dist/main.jsRunning

node dist/main.jsdirectly does not apply migrations (only the Docker entrypoint does). Runyarn prisma:deployfirst, or prefer the Docker images, which migrate automatically.

# Backend

yarn start:dev # dev with hot-reload

yarn build # compile TypeScript (nest build)

yarn lint # eslint --fix

yarn test # unit tests (Jest)

yarn test:e2e # e2e tests

yarn test:cov # coverage

# Frontend

yarn build:client # build the dashboard

cd client && npm run dev # Vite dev server (proxies to the API)

# Prisma

yarn prisma:generate # generate the client

yarn prisma:migrate # migrate dev

yarn prisma:studio # Prisma Studioflux-api/

├── src/

│ ├── common/ # decorators, guards, interceptors

│ ├── config/ # CORS, etc.

│ ├── core/

│ │ ├── prisma/ # schema, migrations, PrismaService

│ │ ├── redis/ # Redis client

│ │ ├── telegram/

│ │ │ ├── engines/ # InstanceEngine, GramJsEngine, normalized types

│ │ │ ├── services/ # sync, settings, event bus, instances...

│ │ │ ├── views.ts # ChatView, MessageView, MediaView...

│ │ │ ├── telegram.manager.ts # orchestrates lifecycle + session.status

│ │ │ └── telegram.module.ts

│ │ └── webhooks/ # service, dispatcher, worker, signing, types

│ ├── modules/

│ │ ├── auth/ # controller, DTOs, entities, guards

│ │ ├── users/

│ │ ├── telegram/ # controller, DTOs, entities, messaging service

│ │ ├── webhooks/ # controller, DTOs, entities

│ │ ├── health/

│ │ └── dashboard/

│ ├── app.module.ts

│ └── main.ts # bootstrap, OpenAPI/Scalar, BigInt shim

├── client/ # Vue 3 SPA (base /dashboard/)

├── docker/s6-overlay/ # image service tree (s6-rc.d + scripts)

├── docker-compose.yml # runs the single image

├── docker-compose.dev.yml # dev infra only (Postgres + Redis)

├── Dockerfile # 2 stages: builder, runtime

├── prisma.config.ts

└── README.md

No variable is required. The fields below are auto-derived or auto-generated when absent. Set only what you want to pin.

Security: auto-generated secrets use a CSPRNG and live in

DATA_DIR(secrets.json, permission600) — mount a persistent volume so they do not rotate on every restart. Weak placeholders from old templates (e.g.change-me-...) are treated as empty and replaced with strong values. In production, serve behind TLS and restrictCORS_ORIGIN.

Flux ships as a single linux/amd64 image that bundles the API,

PostgreSQL and Redis in one container (supervised by s6-overlay). It is

published to Docker Hub and GHCR on every release, tagged X.Y.Z, X.Y and

latest.

docker compose up -ddocker-compose.yml runs the image with a /data volume — that is the whole

stack. (For local development against host-run code, docker-compose.dev.yml

brings up just Postgres + Redis.)

docker run -d -p 3000:3000 -v flux_data:/data pedrooaj/flux-api # Docker Hub

docker run -d -p 3000:3000 -v flux_data:/data ghcr.io/pedrol3m0z/flux-api # GHCRdocker build -t flux-api .

JWT_SECRET,API_KEYandTELEGRAM_SESSION_SECRETare auto-generated and persisted underDATA_DIR— keep the/datavolume so they stay stable. Set them explicitly only to pin known values. Migrations apply automatically on container start.

- On-demand history (cursor-paginated messages)

- Media send and download (photo/video/document, avatars)

- Event system (status, messages, read receipts, reactions)

- Webhooks for Telegram events (M2M, HMAC, retry, log)

- Telegraf engine (Bot API)

- Group participants (N↔N)

- Search across chats/messages

- API (OpenAPI): /docs— Scalar UI

- Dashboard guide: the "Help" section in the app

- Contributing: CONTRIBUTING.md

- Branches & Git flow: BRANCHING.md

- Code of Conduct: CODE_OF_CONDUCT.md

- Security: SECURITY.md

- Maintenance/Releases: docs/MAINTAINING.md

Flux is free and open source. If it saves you time or you just want to back the work, you can support development:

You can also help for free by starring the repo ⭐ or sponsoring on GitHub.

Apache License 2.0 © Pedro Lemos

Built with ❤️ on NestJS + Vue + Telegram