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