API reference

DNI public endpoints

The two requests the DNI snippet makes from a visitor's browser: lease a number from a pool, and keep the lease alive.

These are the only endpoints on the platform meant to be called from a stranger's browser. They take no API key: a request identifies itself with a number pool's public key, which can do exactly one thing — lease a number from that pool. You rarely call them yourself. The DNI snippet does, and this page is here for the teams who would rather write that themselves.

The point of a lease is attribution. A visitor arrives on your landing page from an ad, sees a number that is theirs for the next half hour, and rings it — and because the number was leased to a session that recorded the page's gclid, the call arrives already knowing which click paid for it. Pools are created and managed through Pools and DNI.

Base URL and the credential

Base URL
https://api.buy3.io/api/public/dni
  • The credential is the pool's publicKeypk_ and 24 hex characters — sent in the body. It is meant to be published: it sits in your page's source for anybody to read.
  • It authorises nothing else. It cannot read a call, a session, a pool or a workspace. Listing a pool's sessions needs a console session, through Pools and DNI.
  • There is no Authorization header here, and no agencyId: a pool key names its own workspace.

Cross-origin rules

  • Any origin may call these two endpoints, with GET and POST. Your landing pages are on your domains, not ours, and there is no list of origins this platform could hold.
  • No credentials. That is the price of the wildcard origin, and it is not negotiable: an origin of * that also accepted credentials would let any page on the internet make requests as a signed-in console user.
  • So call with fetch and credentials: "omit". Never navigator.sendBeacon — it always sends credentials, and a wildcard origin refuses it. A beacon that fails silently on unload is the worst way for this to break.
  • A preflight is cached for a day, so a page view does not usually pay for one.
  • Both answers carry Cache-Control: no-store. A shared cache that handed one visitor another visitor's lease would be the single failure this surface exists to prevent.

Rate limits

300 requests a minute per source address, counted separately from every other surface, with standard RateLimit headers on the answer. A visitor makes one lease and a heartbeat every so often, so that is far above a busy office behind one address and far below what draining a pool by script would take. Over the limit the answer is 429.

Malformed extras are dropped, not refused

The page calling this is somebody's storefront, and the honest failure is to lease anyway. A visitor id that is not one is replaced with a fresh one. A URL longer than the column is cut, not refused. A parameter the pool did not ask to keep is dropped. Only a missing or malformed pool is a 400 — without it there is nothing to lease from.

A call is matched back to a lease

When a call arrives on a pool number it is matched to the most recent lease on that number that was live when the call arrived — or that ended less than ten minutes before. Callers read a number, close the tab and then pick up the phone, and those are most of the calls a landing page produces. The captured parameters become the call's tags, with the source dni.

Endpoints

The snippet

GET/api/public/dni/b3dni.js

The JavaScript that does all of this for you. One file serves every pool and every site — it reads its pool key off its own <script> tag and its API address off its own src — so it is cacheable by anybody for an hour, and a fix reaches your visitors without you re-pasting a tag. It takes no parameters and returns no data about your workspace.

No API key. No Authorization header. The request authenticates itself with a value in its body or URL, described below.

Example request

curl "https://api.buy3.io/api/public/dni/b3dni.js"

Responses

  • 200The script, as application/javascript with Cache-Control: public, max-age=3600. It reads data-pool (the pool's public key), data-swap (a CSS selector, .b3-number by default) and data-api (the API base, defaulting to its own src). It replaces the number in the text of every matching element — and of anything carrying data-b3-number — and in any tel: link on or inside it. Add data-b3-format="e164" or data-b3-format="national" to one element to choose how the number is written there. It exposes window.b3dni.swap(), window.b3dni.refresh() and window.b3dni.number, and fires a b3dni:number event on document when a number is set — which is what a single-page app hooks. If it cannot lease, it does nothing and the page keeps its own number.
/*! b3dni - dynamic number insertion. Fails silent: on any error the page keeps its own number. */

Lease a number

POST/api/public/dni/lease

Called once, when the page loads. Opens a session for this visitor, leases one number from the pool to it, stores the URL parameters the pool asked to keep, and answers with the number to show. Store visitorId and send it back next time: a returning visitor is given the number they had, so the number on the page and the number in your analytics agree. Creates a session row; it costs nothing and charges nothing.

Pool public key. Identified by the number pool's public key. It is safe to ship in a web page and can only lease numbers from its own pool.

Request body

NameTypeDescription
poolRequiredstringThe pool's public key, from the pool's snippet.pk_ followed by 24 hexadecimal characters
visitorIdOptionalstringThe id this visitor was given last time. Keep it in the browser and send it back. One that does not match the expected shape is quietly replaced with a fresh one rather than refused.up to 64 characters · kept when it is 8–64 letters, digits, _ or -
paramsOptionalobjectThe page's URL parameters. Only the keys the pool lists are kept — exactly, or by a prefix* rule — so a landing URL that carries an email address never reaches a call record. What survives becomes the call's tags with the source dni.at most 20 keys survive, in the pool's own order · keys are lower-cased; values are trimmed to 256 characters · scalars only — an array contributes its first value, an object is dropped
urlOptionalstringThe landing URL. Longer is cut, not refused.up to 2000 characters
referrerOptionalstringWhere the visitor came from. Longer is cut, not refused.up to 2000 characters

Example request

curl -X POST "https://api.buy3.io/api/public/dni/lease" \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.example.com" \
  -d '{
    "pool": "pk_3f9a1c2b7d8e4f60a1b2c3d4",
    "visitorId": "v_5c1e0a9b7d3f2e1c4b6a8d90",
    "params": { "gclid": "Cj0KCQjw", "utm_source": "google" },
    "url": "https://www.example.com/medicare?gclid=Cj0KCQjw&utm_source=google"
  }'

Responses

  • 200The lease. number carries three spellings of the same number, sessionId is a large integer sent as a string, and ttlSeconds is how long the lease lasts without a heartbeat. overflow: true means the number is shared with another visitor or is the fallback, so attribution is best effort.
  • 200 (fallback)Every number was held and the pool has a fallback. Nothing was leased, so there is no session to keep alive and sessionId is null. Show the number and stop — do not send heartbeats.
{
  "number": {
    "e164": "+16195550134",
    "national": "(619) 555-0134",
    "display": "(619) 555-0134"
  },
  "visitorId": "v_5c1e0a9b7d3f2e1c4b6a8d90",
  "sessionId": "48211",
  "ttlSeconds": 1800,
  "overflow": false
}

Errors

StatusCodeWhen
400validation_errorpool is missing or is not a pool key. It is the only field that can fail this way.
403origin_not_allowedThe pool lists allowed domains and the browser's Origin — or, failing that, its Referer — is not covered by one. A request that names no page at all is refused too, under a non-empty list: that is what a script looks like.
404pool_not_foundNo pool has that public key.
409pool_pausedThe pool is paused. It leases nothing, and the page keeps its own number.
409pool_emptyThere is no number to lease and no usable fallback.
429More than 300 requests a minute came from this address. This limiter answers with a plain-text sentence rather than the API's JSON error body, so branch on the status rather than on a code.

Keep a lease alive

POST/api/public/dni/heartbeat

Called periodically while the page is open. Pushes the lease's expiry out by the pool's full time-to-live, so a number is not handed to somebody else while a visitor is still looking at it. When heartbeats stop, the lease runs out on its own and the number returns to the pool — there is no janitor and nothing to call when the page closes.

Pool public key. Identified by the number pool's public key. It is safe to ship in a web page and can only lease numbers from its own pool.

Request body

NameTypeDescription
sessionIdRequiredstring | numberThe sessionId the lease answered with. A number is accepted and read as its digits.1–18 digits
visitorIdRequiredstringThe visitor the lease was given to. A session id is a number anybody can count through, so it is only ever looked up together with this — and a wrong pair is the same 404 as a missing row.up to 64 characters

Example request

curl -X POST "https://api.buy3.io/api/public/dni/heartbeat" \
  -H "Content-Type: application/json" \
  -H "Origin: https://www.example.com" \
  -d '{
    "sessionId": "48211",
    "visitorId": "v_5c1e0a9b7d3f2e1c4b6a8d90"
  }'

Responses

  • 200The lease is alive and now runs until expiresAt.
  • 200 (expired)The lease has lapsed, or the pool was paused. This is not an error in the request — it is the state of the lease. Lease again, or leave the page's own number in place.
{
  "ok": true,
  "expired": false,
  "ttlSeconds": 1800,
  "expiresAt": "2026-09-20T14:34:07.000Z"
}

Errors

StatusCodeWhen
400validation_errorsessionId is missing or is not 1–18 digits, or visitorId is missing.
403origin_not_allowedThe pool lists allowed domains and this page's host is not covered by one.
404session_not_foundNo such session — including the case where the sessionId and the visitorId do not belong together. The two are indistinguishable on purpose.
429More than 300 requests a minute came from this address. This limiter answers with a plain-text sentence rather than the API's JSON error body, so branch on the status rather than on a code.