Embeddable sales telephony for any product — "Stripe Connect for outbound calling".
Quickstart · Architecture · API · SDK · React · Contributing
Your app keeps its users, leads and CRM records. Dialer owns everything that makes a phone ring: rep presence, queues, power-dial loops, call state, recordings, compliance gates, caller-ID selection, routing and signed webhooks. You integrate with a server SDK, a React package and a webhook endpoint.
<DialerProvider token={session.token} apiUrl={apiUrl} realtimeUrl={realtimeUrl}>
<PowerDialer
campaignId={campaignId}
onLead={(lead) => showCrmRecord(lead.externalId)}
onDisposition={({ callId, code, notes, lead }) => saveToCrm(lead?.externalId, callId, code, notes)}
/>
</DialerProvider>- Power, preview and manual dialing — a session reducer drives the whole loop: reserve, present, dial, talk, wrap up, repeat.
- Swappable media plane — the
MediaPlaneinterface covers call control; FreeSWITCH (WebRTC for reps, SIP trunk out) implements it, and a fake implementation runs the whole platform with no telephony infrastructure. - Pluggable carrier layer — carriers are adapters behind one
TelephonyCarrierinterface, resolved from a registry at runtime. Different organizations can run different carriers at the same time. BulkVS and a fake ship in the box; a new one is a factory and aregisterCarriercall. - Realtime — WebSocket gateway backed by NATS JetStream, with replay and resume so a refreshed tab rejoins a live call.
- Recordings — streamed to any S3-compatible store (S3, R2, GCS, MinIO).
- Compliance built in — DNC lists, calling-window enforcement and consent checks gate every origination attempt.
- Signed webhooks — HMAC signing and verification that run on Node, the browser and edge runtimes.
- Multi-tenant from the start — organizations, API keys, rep JWTs and RBAC, with credentials encrypted at rest using AES-256-GCM.
┌───────────────────── your product ─────────────────────┐
│ server: @dialer/sdk (API key) browser: @dialer/react (rep JWT) │
└──────────┬────────────────────────────────┬────────────┘
│ REST :4000 │ WS :4001 (+ SIP/WSS :7443 media)
┌───────────────────▼────────────────────────────────▼───────────────────┐
│ CONTROL PLANE │
│ apps/api (Fastify) apps/realtime (ws + JetStream replay) │
│ apps/worker (dialer loop, call state machine, webhooks, recordings) │
│ apps/telephony (:4002, hosts MediaPlane, NATS request/reply) │
│ Postgres ── Redis (locks/presence) ── NATS JetStream ── S3/MinIO │
└──────────────────────────────┬─────────────────────────────────────────┘
│ ESL 8021 / recordings volume
┌──────────────────────────────▼─────────────────────────────────────────┐
│ MEDIA PLANE MediaPlane adapter — FreeSWITCH (SIP/WSS for reps, SIP │
│ trunk to carrier) | fake · coturn (TURN behind NAT) │
│ CARRIER TelephonyCarrier adapter — bulkvs | fake | your own │
└────────────────────────────────────────────────────────────────────────┘
Requires Node >= 22, pnpm 10 and Docker.
pnpm install
cp .env.example .env
docker compose up -d # postgres, redis, nats (JetStream), minio
pnpm db:migrate
pnpm db:seed # prints org, API keys, reps and a campaign
pnpm dev # api, realtime, worker, telephony, admin, example-crmOpen http://localhost:4200, sign in as a seeded CRM user, click Queue open leads, then Start dialing.
Ports: API 4000, realtime 4001, telephony 4002, admin 4100, example CRM
4200.
TELEPHONY_PROVIDER=fake and CARRIER_PROVIDER=fake are the defaults, so the
platform runs end to end without a carrier account. For real audio, switch to
freeswitch and start the media plane with
docker compose --profile telephony up -d --wait; see
docs/FREESWITCH.md.
With the fake carrier, the destination's last four digits pick the outcome:
0000 unknown number, 0001 busy, 0002 decline, 0003 provider unavailable,
0004 no answer, 0005 answer then hangup after 4s, 0006 early media then
answer, anything else answers in about 1.2s.
Four steps, in full in the Quickstart.
// 1. Server: mint a short-lived rep token for one of your users.
const dialer = new Dialer({ apiKey: process.env.DIALER_API_KEY!, baseUrl: "http://localhost:4000" });
const s = await dialer.reps.createSession({ externalUserId: user.id, displayName: user.name });
// 2. Server: create a queue and a campaign, once.
const queue = await dialer.queues.create({ name: "CRM leads", strategy: "PRIORITY" });
const campaign = await dialer.campaigns.create({ name: "Outbound", dialMode: "POWER", queueId: queue.id, state: "ACTIVE" });
// 3. Server: push leads keyed by your own record ids.
await dialer.queueItems.enqueueBatch(leads.map((l) => ({
campaignId: campaign.id, phone: l.phone, externalId: l.id, externalSource: "my-crm",
})));
// 4. Browser: drop <PowerDialer /> into your app (see the snippet above).Outcomes come back through callbacks in the browser and
signed webhooks on your server. A complete reference
integration lives in apps/example-crm.
Carriers are pluggable. packages/telephony defines the whole contract —
TelephonyCarrier (numbers, CDRs, error normalization, optional STIR/SHAKEN and
CNAM) and CarrierSipProfile (how the media plane reaches the trunk). Nothing
above that layer is carrier-specific.
Adapters register a factory by name, and each organization picks its carrier through a provider account, so several carriers can run side by side in one deployment.
import { registerCarrier } from "@dialer/services";
registerCarrier("my-carrier", (credentials, sipConfig) => new MyCarrier(credentials, sipConfig));Built in today: bulkvs (@dialer/carrier-bulkvs) and
fake (@dialer/test-provider), both registered in
packages/services/src/bootstrap.ts. Use the fake as the reference
implementation, and see docs/TELEPHONY.md for the interface
and docs/BULKVS.md for a real adapter end to end.
apps/
api/ REST API (Fastify, :4000)
realtime/ WebSocket gateway (:4001)
worker/ JetStream consumers + housekeeping
telephony/ MediaPlane host: fake | FreeSWITCH ESL (:4002)
admin/ Vite admin console (:4100)
example-crm/ Reference integration (:4200)
packages/
core/ Domain enums, state machines, zod API schemas, events, routing, compliance
services/ Business logic (calls, dialer, queues, reps, recordings, webhooks, carriers)
database/ Drizzle schema, migrations, seed
bus/ NATS JetStream + Redis helpers (locks, keys)
auth/ API keys, rep JWT, AES-256-GCM credential encryption, RBAC
telephony/ Carrier-neutral contracts: TelephonyCarrier, MediaPlane, events
freeswitch/ FreeSWITCH MediaPlane (ESL) — a MediaPlane implementation
carrier-bulkvs/ BulkVS — a TelephonyCarrier implementation
test-provider/ FakeMediaPlane / FakeCarrier — implementations for local dev
webhooks/ HMAC signing and verification (Node, browser, edge)
sdk/ @dialer/sdk — REST client, RealtimeClient, Phone, PowerDialerSession
react/ @dialer/react — DialerProvider, PowerDialer, Phone, hooks
observability/ pino logger, optional OpenTelemetry
infra/ Dockerfiles, FreeSWITCH config, coturn, Kubernetes manifests
tests/ e2e (Playwright), load, concurrency
| Command | Purpose |
|---|---|
pnpm dev |
Run every app with Turbo |
pnpm typecheck |
Typecheck the workspace |
pnpm test:unit |
Unit tests |
pnpm test:integration |
Integration tests (needs Docker services) |
pnpm test:e2e |
End-to-end power-dial run |
pnpm test:load |
Load harness |
pnpm db:migrate / pnpm db:seed |
Apply migrations / seed dev data |
pnpm release |
Build @dialer/sdk and @dialer/react tarballs into release/ |
| Start here | QUICKSTART · ARCHITECTURE |
| Reference | API · SDK · REACT · EVENTS · WEBHOOKS |
| Integrating | INTEGRATIONS · CRM_ADAPTERS |
| Telephony | TELEPHONY · FREESWITCH · BULKVS · RECORDINGS · ROUTING |
| Operating | DEPLOYMENT · SCALING · RUNBOOK · SECURITY · COMPLIANCE |
Issues and pull requests are welcome — see CONTRIBUTING.md for setup, checks and conventions, and docs/CONTRIBUTING-AGENTS.md for a dense map of the subsystems. Security issues go through SECURITY.md, not public issues.