0800 310 1010
← All articles
CRM & integrations · 12 min read

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
    Authorization
    header).
  • For every call on your account we POST a JSON event to
    <your-url>/sessionUpdate
    at each state change.
  • 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
    Content-Encoding: gzip
    header — any modern HTTP framework decodes this for you transparently).

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:

  • sessionId
    — UUID. Use this as your primary key. Stable across all events for the same call.
  • direction
    —
    inbound
    or
    outbound
    .
  • state
    —
    offered
    |
    inboundConnected
    |
    outboundConnected
    |
    disconnected
    .
  • accountNumber
    — your NumberGroup account identifier (e.g.
    NTS-TEST
    ).
  • inboundIdentity
    — the calling number (CLI), E.164 without the leading
    +
    .
  • inboundDestination
    — the dialled number (your DDI).
  • outboundIdentity
    — the number presented on the outbound leg (after any caller-ID rewrite).
  • outboundDestination
    — the destination the call was routed to (a SIP extension, mobile, PSTN number, etc.).
  • inboundConnectedDurationSeconds
    — only on
    disconnected
    . Time the caller was connected to your platform.
  • outboundConnectedDurationSeconds
    — only on
    disconnected
    . Time the two legs were bridged together. This is the “talk time” you want for billing / reporting.

Recordings

The recording arrives as a

PUT
shortly before the final
disconnected
event. The URL path is:

PUT /sessionRecording/{sessionId}_{legNumber}.wav
  • {sessionId}
    matches the
    sessionId
    from the event stream — that’s your join key.
  • {legNumber}
    starts at
    0
    and increments if the call is transferred and a new recording leg starts. For most calls you’ll only ever see
    _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

/tmp
write 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.

Phone-call conversions in Google Ads need a phone number, a conversion time, and (ideally) a value. Fire a ConversionUploadService call from

handleEvent
when
outboundConnectedDurationSeconds
is 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
    offered
    before
    inboundConnected
    , but build your receiver to be idempotent on
    sessionId
    +
    state
    just in case.
  • Respond fast. Return
    200
    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.
  • 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.

See what Membership includes →Browse phone numbers →
Related reading