The bottleneck: disconnected systems cost real money
Most growing real estate sales teams run two systems that never talk to each other: a phone system and a CRM. Reps make calls from a desk phone or a softphone, and the CRM has no idea the call happened unless someone manually logs it afterward. In practice, that logging is inconsistent. Leads go stale. Follow-ups get missed. Managers can't see call volume or outcomes without asking reps to self-report, which is unreliable at best.
The fix isn't a better CRM or a better phone system individually — it's wiring the two together so a call event on the telecom side becomes a real-time event on the web side. That means the backend needs to listen to the PBX, not just store data the PBX eventually exports.
This is the architecture for doing that: a self-hosted Asterisk PBX bridged into a MERN-stack CRM, with call events pushed live to the sales rep's dashboard before they even pick up the phone.
The architecture: a high-level view
Three layers, each doing one job:
- Telecom backend — Asterisk handles the actual calls (SIP trunks, dialing, call detail records).
- The bridge — A Node.js service listens to Asterisk's event stream and correlates call events with CRM data.
- The frontend — The CRM's React dashboard receives those events over a WebSocket and updates in real time.

The diagram above shows the flow: a call hits Asterisk, Asterisk's event interface notifies the backend, the backend looks up the caller against the CRM database, and the result gets pushed to the rep's browser.
The important architectural decision here is that Asterisk never talks to MongoDB directly, and the frontend never talks to Asterisk directly. The Node.js layer is the only thing that understands both worlds. That separation keeps each piece replaceable — you could swap Asterisk for a hosted SIP provider later, or swap MongoDB for something else, without touching the other side.
The infrastructure: telecom backend
Asterisk runs containerized via Docker on a standard Ubuntu VPS. Containerizing it isn't just for consistency — it makes upgrades and rollbacks safe, which matters a lot when you're touching a system handling live phone calls. A broken apt upgrade on a bare-metal PBX is a bad afternoon.
A few specifics worth calling out:
- Use
chan_pjsip, notchan_sip. The legacychan_sipdriver is deprecated and has known limitations with modern SIP trunking providers.chan_pjsipis the actively maintained driver and the one Asterisk's own documentation steers you toward for new deployments. - Separate the CDR storage from your application database. Asterisk can write Call Detail Records (CDR) directly to a database. Point that at a dedicated MariaDB instance rather than your MongoDB CRM database. CDR tables grow fast and get queried differently (by Asterisk's reporting tools, not your app), so keeping it separate avoids both query bloat and accidental coupling between your telecom layer and your application layer.
- SIP trunk configuration lives entirely on the Asterisk side. Your Node.js backend never needs to know trunk credentials or codec negotiation details — it only cares about events.
The bridge: how Node.js listens to Asterisk
Asterisk exposes two interfaces relevant here:
- AMI (Asterisk Manager Interface) — a TCP socket that streams raw events:
Dial,Bridge,Hangup, and so on. This is what you subscribe to for real-time call state. - ARI (Asterisk REST Interface) — a more modern, REST/WebSocket-based interface that gives you finer control over call handling (answering, transferring, playing audio) if you need to actively control calls rather than just observe them.
For a CRM integration where you mainly want to observe calls and pop up context, AMI is usually sufficient and simpler. ARI becomes relevant if you want the backend to actively control call routing (for example, programmatically transferring a call based on CRM data).
The NestJS backend maintains a persistent connection to Asterisk's AMI socket and listens for the events that matter:
// Conceptual structure — not a drop-in snippet import { AmiClient } from 'asterisk-ami-client'; @Injectable() export class AsteriskListenerService implements OnModuleInit { private ami = new AmiClient(); async onModuleInit() { await this.ami.connect(process.env.AMI_USER, process.env.AMI_SECRET, { host: process.env.ASTERISK_HOST, port: 5038, }); this.ami.on('event', (event) => { if (event.Event === 'Dial' && event.CallerIDNum) { this.handleIncomingCall(event.CallerIDNum, event.Channel); } if (event.Event === 'Hangup') { this.handleCallEnd(event.Channel); } }); } private async handleIncomingCall(callerId: string, channel: string) { const lead = await this.leadsService.findByPhoneNumber(callerId); if (lead) { this.callGateway.pushLiveCall(lead.assignedRepId, { leadId: lead._id, name: lead.name, status: lead.status, notes: lead.recentNotes, }); } } } The key logic is in handleIncomingCall: the moment Asterisk reports a Dial event with a caller ID, the backend queries MongoDB for a lead matching that phone number, and if found, pushes the result to the correct rep's browser — identified by assignedRepId, not by broadcasting to everyone.
A couple of things that matter in production, not just in theory:
- Normalize phone numbers before matching. Caller ID formats vary (with or without country code, with or without formatting characters). Store and query a normalized format, or every match will silently fail for numbers that don't happen to line up exactly.
- Handle the "no match" case explicitly. Most calls, especially early on, won't match an existing lead. Decide what the UI shows in that case — a plain incoming-call notification, or an option to create a new lead on the spot — rather than letting the frontend silently do nothing.
- Reconnect on AMI disconnect. TCP sockets drop. Wrap the AMI connection in reconnect logic with backoff, or a VPS reboot or network blip silently kills your entire integration until someone notices calls aren't popping up anymore.
The real-time UI: pushing events to React
Once the backend has resolved a caller ID against a lead, it pushes that payload over Socket.io to the specific rep's browser session:
// Gateway side (NestJS) @WebSocketGateway() export class CallGateway { @WebSocketServer() server: Server; pushLiveCall(repId: string, payload: LiveCallPayload) { this.server.to(`rep:${repId}`).emit('incoming-call', payload); } } // Frontend side (React) useEffect(() => { socket.on('incoming-call', (payload: LiveCallPayload) => { setActiveCall(payload); setModalOpen(true); }); return () => { socket.off('incoming-call'); }; }, []); When the modal fires, the rep sees the lead's name, current status, and recent notes before they've even picked up the phone — the "screen pop" pattern familiar from traditional call center software, built on top of infrastructure you control rather than a vendor's black box.
When the call ends (the Hangup AMI event), the backend calculates duration from the event timestamps and writes it to the lead's record in MongoDB, along with a link to the call recording. Asterisk can be configured to record calls to disk; serving those recordings through Nginx as static files (with proper access control — these are often sensitive) keeps playback simple without routing audio data through your application server.
Business impact: why this is worth building
The technical payoff maps directly to business outcomes:
- Zero manual logging. Every call is recorded against the right lead automatically. Reps can't forget to log a call because there's nothing to manually log.
- No lead leakage. A call from an existing lead is identified and surfaced immediately — no missed context, no "who is this again" moment that costs trust with a prospect.
- Real visibility for managers. Call volume, duration, and outcomes become queryable CRM data instead of anecdotal self-reporting.
- Faster rep response. Seeing lead status and notes before answering changes how the rep opens the call — which, anecdotally, is the kind of thing that moves close rates, even if it's hard to isolate as a single metric.
None of this requires an expensive hosted call-center platform. A self-hosted Asterisk instance, a backend that understands its event stream, and a CRM that can receive pushed events gets you there, built on infrastructure you own.
Closing thought
The pattern here generalizes beyond telecom. Any system that emits events — a payment gateway's webhooks, an IoT device's MQTT stream, a legacy system's polling endpoint — can be bridged into a modern web application the same way: an event listener that understands the source, a lookup against your application's data, and a push to the client over WebSockets. Asterisk is just a particularly good example because the event stream is rich and the business case (lost revenue from disconnected systems) is easy to see.
Malek Mostafa is a full-stack developer working across MERN-stack CRMs, real-time systems, and backend integrations.