Real-time call events and recording webhooks: build your own CRM, analytics or call-tracking integration
NumberGroup pushes every call state change (offered → connected → disconnected) and the resulting WAV recording to a webhook URL you control. Full protocol reference, lifecycle, a working Node.js / Next.js receiver, and integration patterns for HubSpot, Google Ads offline conversions and Slack.
The other webhook post on this blog (webhooks explained) covers the per-number start-of-call and end-of-call hooks — the ones with regex variable extraction that can influence routing. This post is about the other integration surface: a real-time, account-wide stream of every call event plus the recording, pushed to a single HTTPS endpoint you control.
If you’re building CRM logging, marketing attribution, a Slack live-feed of calls, an internal call-tracking dashboard, or AI-powered call summarisation — this is the surface you want. It is the same protocol our own dialler / portal uses internally.
How it works at a glance
- You give us one HTTPS URL and a shared secret (sent in the
header).Authorization - For every call on your account we POST a JSON event to
at each state change.<your-url>/sessionUpdate - When the call ends and is recorded, we PUT the WAV file to
.<your-url>/sessionRecording/{sessionId}_{leg}.wav - All bodies are gzip-encoded (the standard
header — any modern HTTP framework decodes this for you transparently).Content-Encoding: gzip
That’s the whole protocol. The rest of this post is the reference, a working receiver, and patterns for the most common integrations.
The call lifecycle: which events you’ll see
A typical inbound call that gets answered and recorded fires four POSTs and one PUT, in this order:
POST /sessionUpdate state: "offered" <- we received the call
POST /sessionUpdate state: "inboundConnected" <- caller leg is up
POST /sessionUpdate state: "outboundConnected" <- destination answered
PUT /sessionRecording/<id>_0.wav <- recording uploaded
POST /sessionUpdate state: "disconnected" <- call ended (+ durations)A call that goes to voicemail, gets rejected, or hits a hunt-group timeout will be missing some states. The
disconnected event is the one to use as a CRM “log this call now” trigger — it’s the only one guaranteed to fire on every call, and it’s the only one that carries the duration counters.Event payload reference
Every
POST /sessionUpdate body is JSON. Fields:
— UUID. Use this as your primary key. Stable across all events for the same call.sessionId
—direction
orinbound
.outbound
—state
|offered
|inboundConnected
|outboundConnected
.disconnected
— your NumberGroup account identifier (e.g.accountNumber
).NTS-TEST
— the calling number (CLI), E.164 without the leadinginboundIdentity
.+
— the dialled number (your DDI).inboundDestination
— the number presented on the outbound leg (after any caller-ID rewrite).outboundIdentity
— the destination the call was routed to (a SIP extension, mobile, PSTN number, etc.).outboundDestination
— only oninboundConnectedDurationSeconds
. Time the caller was connected to your platform.disconnected
— only onoutboundConnectedDurationSeconds
. Time the two legs were bridged together. This is the “talk time” you want for billing / reporting.disconnected
Recordings
The recording arrives as a
PUT shortly before the final disconnected event. The URL path is:PUT /sessionRecording/{sessionId}_{legNumber}.wav
matches the{sessionId}
from the event stream — that’s your join key.sessionId
starts at{legNumber}
and increments if the call is transferred and a new recording leg starts. For most calls you’ll only ever see0
._0.wav- The body is the WAV file itself, gzip-encoded for transit. Decompress, store, done.
- Typical size for a 1-minute call is around 1.5 MB uncompressed (mono 8 kHz PCM). Calls that aren’t recorded (Member opted out, or the leg never connected) produce no PUT.
Authentication
Every request — POST and PUT — carries an
Authorization header. The value is a shared secret you choose when configuring the webhook. Treat it like a password: store it in env vars on your receiver, compare in constant time, reject anything else with a 401. We will never send a request without the header set.A working Next.js receiver in ~60 lines
This handles both event POSTs and recording PUTs in one route, using a path catch-all. Drop it into any Next.js app deployed on Vercel — gzip is decoded automatically by the runtime.
// src/app/api/numbergroup/[...path]/route.ts
import { NextRequest } from "next/server";
import { writeFile } from "node:fs/promises";
import path from "node:path";
const SECRET = process.env.NG_WEBHOOK_SECRET!;
function unauthorized() {
return new Response("unauthorized", { status: 401 });
}
export async function POST(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.headers.get("authorization") !== SECRET) return unauthorized();
if (params.path[0] !== "sessionUpdate") return new Response("not found", { status: 404 });
const event = await req.json();
await handleEvent(event);
return new Response("ok");
}
export async function PUT(
req: NextRequest,
{ params }: { params: { path: string[] } },
) {
if (req.headers.get("authorization") !== SECRET) return unauthorized();
if (params.path[0] !== "sessionRecording") return new Response("not found", { status: 404 });
const filename = params.path[1]; // e.g. "abc_0.wav"
const wav = Buffer.from(await req.arrayBuffer());
await writeFile(path.join("/tmp/recordings", filename), wav);
return new Response("ok");
}
async function handleEvent(event: any) {
// your CRM / analytics / DB write goes here
console.log(event.state, event.sessionId, event.inboundIdentity, "->", event.outboundDestination);
}For production: replace the
/tmpwrite with an S3 / R2 upload (Vercel functions don’t have persistent disk), and move the CRM write into a queue so a slow downstream API never times out our delivery.Common integration patterns
CRM call logging (HubSpot, Salesforce, Pipedrive)
Listen only for
state: "disconnected". That event has everything you need: both numbers, both durations, the session ID. In handleEvent, look up the contact by inboundIdentity (inbound) or outboundDestination(outbound), then POST a Call / Activity record. The CRM-specific posts on this blog have the API calls written out — this receiver replaces the “poll the calls report every hour” bit they assume.Google Ads offline conversions
Phone-call conversions in Google Ads need a phone number, a conversion time, and (ideally) a value. Fire a ConversionUploadService call from
handleEvent when outboundConnectedDurationSecondsis at least 60 (or whatever your “quality call” threshold is). This is dramatically more accurate than the GCLID-only path because you’re scoring on actual conversation length, not just “call started”.Slack live-call feed
For a real-time team feed, post to a Slack incoming webhook on
state: "offered"(“phone ringing sales”) and on state: "disconnected" with the duration. This is the simplest possible “always know what calls are happening” integration — useful for small teams who don’t want to live in a dialler UI.AI call summaries
When the recording PUT arrives, push the WAV at Whisper (or any ASR), then run the transcript through an LLM with a summarisation prompt, then write the summary back to the CRM record you created on
disconnected. The session ID ties everything together. Members doing this typically post the summary as a Slack thread reply to the live-feed message above.Gotchas worth knowing
- Order is not strictly guaranteed. 99% of the time you’ll see
beforeoffered
, but build your receiver to be idempotent oninboundConnected
+sessionId
just in case.state - Respond fast. Return
within ~2 seconds. Do the slow work (CRM API, S3 upload) in a queue / background task. We will retry on non-2xx but slow responses can pile up.200 - Recordings can be larger than function memory limits. A 30-minute call is ~30 MB. Stream the body to storage rather than buffering it all if you’re on tight serverless limits.
- Test with curl first. The trace in the protocol section above is real — replay it against your receiver locally before you point us at it.
- It’s account-wide. If you only want events for some DDIs, filter in your receiver on
.inboundDestination
Getting started
Ring 0800 310 1010 or email help@numbergroup.com and tell us “please point my session-update webhook at your-url”. We’ll set the URL and shared secret, place a test call, and you’ll see the events land. There’s no portal toggle — this is one of those things that’s easier to ask us to do than build a UI around.
Move your phones to us. We'll save you money.
Memorable numbers, unlimited UK numbers, every smart feature and a UK team — all included with Membership at £149/mo. No contract.