The DNI snippet
Install the dynamic number insertion script, tell it which numbers on the page to replace, drive it from a single-page app, and work out why a number did not swap.
After this page you can put the snippet on a site, control exactly which numbers it rewrites, keep it working in a single-page app, decide where it sits relative to your consent banner, and diagnose a page where the number did not change.
The DNI snippet is a small script you add to your website. It asks the platform for a number from a pool, swaps it into the page in place of the number already there, and keeps the lease alive while the visitor stays.
The tag
Copy it from the pool's Snippet tab — it already contains the pool's public key, so there is nothing to fill in. Paste it once per page, anywhere; just before the closing body tag is fine. It loads asynchronously and never blocks rendering.
<script
src="https://api.buy3.io/api/public/dni/b3dni.js"
data-pool="pk_4f1d2c3b4a5e6f708192a3b4"
data-swap=".b3-number"
async></script>| Attribute | Required | What it does |
|---|---|---|
data-pool | Yes | The pool's public key, pk_…. It identifies the pool and authorises nothing else, which is why it is safe in page source. |
data-swap | No | A CSS selector for the elements to rewrite. It defaults to .b3-number. Elements carrying data-b3-number are always included as well. |
data-api | No | Overrides the origin the script calls. It is read off the script's own src otherwise, so the same file works on every deployment and custom domain. |
async | No | Recommended. Nothing on the page waits for the script. |
What it rewrites
Put your usual number in the page as normal and mark the element. For each matching element the script rewrites the first phone-shaped run of text inside it, and sets the href of any tel: link on or inside it. An element with no phone-shaped text and no child elements has its whole text replaced.
<!-- text and the tel: link are both rewritten -->
<a class="b3-number" href="tel:+18005550100">(800) 555-0100</a>
<!-- the attribute works instead of the class -->
<span data-b3-number>(800) 555-0100</span>
<!-- written in E.164 wherever this element is used -->
<span class="b3-number" data-b3-format="e164">+18005550100</span>data-b3-format="e164"writes+12145550142;data-b3-format="national"writes(214) 555-0142. Without it the number is written the way the platform displays it.- A rewritten element gets
data-b3-swapped="+1…", which is the quickest way to confirm a swap in the browser's inspector. tel:links are always set to E.164, whatever the visible format is.- The selector goes into an attribute you paste, so the console only accepts the characters a selector needs. Keep it simple: a class is easier to get right than a structural selector.
The two requests it makes
| Request | When | What it does |
|---|---|---|
Lease — POST /api/public/dni/lease | Once, as the script loads. | Sends the pool key, the visitor id, the page URL, the referrer and the whole query string. Answers with the number to show and a session id. |
Heartbeat — POST /api/public/dni/heartbeat | Every 60 seconds while the tab is visible. | Keeps the lease alive. When the heartbeats stop, the number returns to the pool after the pool's session length. |
{
"number": { "e164": "+12145550142", "national": "(214) 555-0142", "display": "(214) 555-0142" },
"visitorId": "v_9f2a4c7e1d0b8a6f5e4d3c2b",
"sessionId": "48211",
"ttlSeconds": 900,
"overflow": false
}sessionIdisnullwhen the pool was full and the fallback number was shown. There is no lease to keep alive, so no heartbeat is sent.overflow: truemeans the number is shared with another visitor, or is the fallback. Any call on it is attributed best-effort at most.- Heartbeats are skipped while the tab is hidden and sent immediately when it becomes visible again after a gap, so a background tab does not hold a number for hours.
- A heartbeat for a lease that has already expired is answered
expired, and the script leases again rather than reviving it. Reviving would put two people on one number with nothing flagged. - The whole query string is sent and the server keeps only what the pool's capture list allows, so changing that list takes effect without anybody re-pasting a tag.
The visitor id
The script mints a random id such as v_9f2a4c7e1d0b8a6f5e4d3c2b and keeps it in localStorage under b3dni_vid, falling back to a first-party cookie of the same name when storage is blocked. It identifies a browser to one pool's owner and nothing else, and it is what makes page two of a visit show the number page one did.
Single-page apps
The script runs once. A router that replaces the markup without a page load leaves the new markup unswapped, so call the global after each render.
// After your router renders a new view
if (window.b3dni && window.b3dni.loaded) {
window.b3dni.swap(); // rewrite the numbers in the new markup
}
// Only when the new view is a genuinely new visit — it leases again
// window.b3dni.refresh();
// React to the number as soon as it is known
document.addEventListener("b3dni:number", function (event) {
console.log("leased", event.detail.number.e164, "shared:", event.detail.overflow);
});On window.b3dni | What it does |
|---|---|
loaded | true once the script has started. Check it before calling anything else. |
number | The leased number as { e164, national, display }, or null before the lease answers. |
overflow | true when the number is shared or is the fallback. |
swap() | Rewrites every matching element again. Cheap, and safe to call as often as you render. |
refresh() | Leases again. Use it for a genuinely new visit, not for a new view — a returning visitor keeps the lease they already hold. |
b3dni:number event fires on document when a number is set, with detail.number and detail.overflow. It is the hook to use when you need the number before your own markup exists.Consent, and what leaves the browser
- The script sends the page URL, the referrer and the query string, and stores a random id. It reads no cookie of yours and never sends credentials — the lease endpoint refuses credentialed requests by design.
- Only the parameters on the pool's capture list are stored. Everything else is discarded server-side, which is the control to use when a landing URL can carry personal data.
- The visitor's address and user agent are recorded with the session, and the pool's owner can read them. They are your own site's traffic.
- Where consent is required before storage or measurement, load the tag after consent — through your tag manager's consent gate, or by injecting it once the banner is accepted. There is no half-on mode, and there should not be.
- Restrict the pool with Allowed domains so the key in your page source cannot be used to lease from anywhere else.
Check it before you ship
- 1
Open the page with a parameter on the URL
Use a private window and something obvious, such as
?utm_source=dni-test. The number on the page should change. - 2
Check the sessions list
The pool's Sessions tab should show a new session holding a number, carrying that parameter and your landing URL.
- 3
Reload the page
The number should stay the same. If it changes, the visitor id is not persisting.
- 4
Dial the number that is on the page
The call should appear in the call log with the parameter as a tag, and the session row should link to it.
- 5
Open the page in a second browser
A different number should appear. The same number in both means the pool has one leasable number, or is out.
When the number does not change
The script fails silent: on any error the page simply keeps the number already in its HTML. A tracking tag must never be the reason a phone number is missing from a page. That makes the browser's network tab the place to look — the lease request's status says which of these it is.
| What you see | What it means | What to do |
|---|---|---|
No request to /lease at all | The script did not load or did not start: a blocked request, or a tag with no data-pool. | Check the tag is on the page and not stripped by a tag manager, and that data-pool carries the key. |
403 origin_not_allowed | The page's host is not covered by the pool's allowed domains. | Add the domain. Remember that a subdomain is covered by its parent, and that a page sending no Origin or Referer is refused under a non-empty list. |
404 pool_not_found | The key does not match a pool. | Copy the tag again from the pool's Snippet tab. |
409 pool_paused | Leasing is switched off on the pool. | Resume it. Live leases stop being extended while it is paused, so the pool drains within one session length. |
409 pool_empty | The pool has no leasable number and no usable fallback. | Add active tracking numbers on the pool's campaign, or set a fallback. |
400 | The pool key is missing or malformed. | The key is pk_ followed by 24 hexadecimal characters. Anything else is refused. |
| A lease, but no swap | Nothing on the page matched the selector, or the matched element held no phone-shaped text and had child elements. | Check data-swap against the markup, or mark the element with data-b3-number. Look for data-b3-swapped in the inspector. |
| The number changes on every view | The visitor id is not persisting, or a router is calling refresh() per view. | Check storage is not blocked, and call swap() rather than refresh() between views. |
| A call with no session tags | The lease had ended more than ten minutes before the call, or the number was the fallback. | Lengthen the pool's session, or add numbers so fewer visitors overflow. |
A worked example
Acme Health have a landing page at https://example.com/dallas that shows (800) 555-0100 in the header and again in the footer.
- They give both elements
class="b3-number"and leave the existing markup — text andtel:link — exactly as it was. - They paste the tag before the closing
bodytag, with the pool's key and the default selector. - They open
https://example.com/dallas?utm_source=dni-test&kw=urgent+carein a private window. Both numbers change to(214) 555-0142and bothtel:links becometel:+12145550142. - The pool's Sessions tab shows one live session for that visitor, carrying
utm_sourceandkw, with the landing URL beside it. - They dial the number. Within seconds the call appears with
utm_source=dni-testandkw=urgent careas tags with the sourcedni, and the session row now links to it. - Their page is a single-page app, so they add
window.b3dni.swap()to their router's after-render hook. Navigating from the hero to the pricing view keeps the same number.
