Dynamic Number Insertion (DNI) explained: per-visitor tracking numbers on your website
How DNI works under the hood, why it beats static tracking numbers, the JavaScript snippet, the SEO NAP-consistency gotcha, and how to wire it up against NumberGroup-allocated number pools.
Dynamic Number Insertion (DNI) is the technique of showing a different phone number on your website depending on who's visiting and where they came from. Each visitor session sees a unique number from a pool, and the call data from that number gets tied back to the original click, keyword and campaign.
It's the practical wiring underneath call-tracking-for-marketing— the bit that lets you say "this call came from the Google Ads campaign for ‘plumber Bristol' on Tuesday at 4:23pm" instead of "someone called".
Static vs dynamic tracking numbers
- Static — one tracking number per channel. 0800 100 1234 for Google Ads, 0800 100 5678 for LinkedIn. Cheap, simple, attributes at channel level. Fine for small marketing budgets.
- Dynamic (DNI) — every visitor gets a session-unique number from a pool. Attribution is 1:1 with the click. Reads keyword, ad group, GCLID, full UTM string. Required for serious paid-search optimisation.
How DNI works under the hood
- You allocate a pool of numbers with NumberGroup — say 30 numbers for a mid-volume site. All point at the same destination.
- A visitor arrives at your site
- JavaScript on the page checks who they are (cookie, referrer, UTM, GCLID)
- The JS calls a small backend service which assigns one number from the pool to that visitor for the duration of their session
- That visitor sees their assigned number everywhere on the site instead of the default
- If they call, NumberGroup logs the call against the assigned number
- Your CRM joins call data + session data on the assigned number → full attribution chain
How big a pool do you need?
Rule of thumb: 1 tracking number per ~10 concurrent sessions. So:
- 50 visitors/day, ~5 concurrent → pool of 5–10 numbers
- 500 visitors/day, ~50 concurrent → pool of 25–50
- 5,000 visitors/day, ~500 concurrent → pool of 50–100 (you start re-using numbers within session timeouts, which is fine)
With Membership, standard UK numbers are unlimited and free — pool size isn't a cost constraint, just a config choice.
A working implementation
The two pieces — plus, where your backend talks to NumberGroup, you'll want the full DNI recipe in our developer docs which walks through the actual Reservations, Translation Request and CDR Feed calls you'll be wiring up.
1. A small backend service that assigns numbers
Endpoint: GET /api/dni/assign?gclid=...&utm=... → returns { "number": "0800 100 1234", "ttl": 1800 }. Backend logic:
- If the visitor has a cookie with an active assignment, return that
- Otherwise pick a number from the pool that hasn't been used in the last 30 minutes
- Store the assignment (cookie ID → number → click metadata) for the session window
- Return the number + TTL
Node.js / Next.js sketch:
// /api/dni/assign — Next.js App Router example
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
const POOL = [
'08001001234','08001001235','08001001236','08001001237',
'08001001238','08001001239','08001001240','08001001241',
];
// Real impl: store in Redis with a TTL on each number
const assigned = new Map(); // number -> { sessionId, expiresAt }
export async function GET(req: NextRequest) {
const c = await cookies();
const sid = c.get('dni_sid')?.value || crypto.randomUUID();
const existing = [...assigned.entries()].find(([, v]) => v.sessionId === sid && v.expiresAt > Date.now());
if (existing) {
return NextResponse.json({ number: existing[0], sessionId: sid, ttl: 1800 });
}
const now = Date.now();
const free = POOL.find((n) => !assigned.get(n) || assigned.get(n).expiresAt < now);
if (!free) {
return NextResponse.json({ number: POOL[0], note: 'pool exhausted — fallback' });
}
assigned.set(free, { sessionId: sid, expiresAt: now + 30 * 60 * 1000 });
const res = NextResponse.json({ number: free, sessionId: sid, ttl: 1800 });
res.cookies.set('dni_sid', sid, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 60 });
return res;
}2. Client-side script that swaps numbers
Put this in your page head:
<script>
(async function () {
// Build the assignment query from UTMs and GCLID
var p = new URLSearchParams(window.location.search);
var q = new URLSearchParams();
['gclid','utm_source','utm_medium','utm_campaign','utm_term','utm_content'].forEach(function (k) {
var v = p.get(k);
if (v) q.set(k, v);
});
var r = await fetch('/api/dni/assign?' + q.toString(), { credentials: 'include' });
var d = await r.json();
if (!d.number) return;
// Replace every default phone number on the page with the assigned one
var DEFAULT = '0800 310 1010';
var EL = document.querySelectorAll('[data-dni-phone]');
EL.forEach(function (el) {
el.textContent = d.number;
if (el.tagName === 'A') el.href = 'tel:' + d.number.replace(/\s/g, '');
});
// Or do a body text replace (safe if your default number is unambiguous):
// document.body.innerHTML = document.body.innerHTML.replaceAll(DEFAULT, d.number);
})();
</script>Mark phone numbers in your HTML with data-dni-phone so the swap only touches the elements you want.
The SEO gotcha
Google's local SEO (Google Business Profile, structured data) relies on NAP consistency — your Name, Address, Phone must be the same on every directory, your Google Business Profile, your website footer and your structured data.
If DNI swaps in 30 different numbers on different visits, Googlebot sees inconsistent NAP and your local pack ranking drops.
The fix:
- Keep one canonical number on your Google Business Profile, in structured data (
schema.org/LocalBusiness), and in your sitewide footer - Detect bots and serve them the canonical number, not a DNI-pool number. The User-Agent check happens server-side in your assign endpoint — return the canonical number when
Googlebot,Bingbot, or other crawler user-agents hit - Don't swap numbers inside structured data blocks. Only swap visible phone numbers on landing pages, hero CTAs, and per-campaign sections
When DNI is overkill
- You spend less than ~£500/mo on paid acquisition. Static per-channel numbers are simpler and good enough.
- You don't actually use Google Ads' smart bidding. If your CPA target is manual, you don't need keyword-level call data.
- You sell almost entirely through web forms, not phone.
Commercial DNI options
If you don't want to build it:
- CallRail — the dominant US/UK product. From £45/mo per number pool. Integrates Google Ads / Meta CAPI / HubSpot / Salesforce.
- Mediahawk — UK-based, popular with agencies. From ~£70/mo.
- Infinity — UK, enterprise focus. From £150/mo.
All three buy underlying numbers from carriers. If you point them at numbers we've allocated to you (as a Member), you keep ownership and avoid being locked into their number pool.
Unlimited UK numbers for DNI pools
Standard UK numbers are unlimited and free with Membership. Allocate a 30-number pool, a 100-number pool, whatever your traffic needs. £149/mo flat.