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
https://api.buy3.io/api/public/dni- The credential is the pool's
publicKey—pk_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
Authorizationheader here, and noagencyId: a pool key names its own workspace.
Cross-origin rules
- Any origin may call these two endpoints, with
GETandPOST. 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
fetchandcredentials: "omit". Nevernavigator.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
- GET
/api/public/dni/b3dni.jsThe snippet - POST
/api/public/dni/leaseLease a number - POST
/api/public/dni/heartbeatKeep a lease alive
The snippet
/api/public/dni/b3dni.jsThe 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.
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/javascriptwithCache-Control: public, max-age=3600. It readsdata-pool(the pool's public key),data-swap(a CSS selector,.b3-numberby default) anddata-api(the API base, defaulting to its ownsrc). It replaces the number in the text of every matching element — and of anything carryingdata-b3-number— and in anytel:link on or inside it. Adddata-b3-format="e164"ordata-b3-format="national"to one element to choose how the number is written there. It exposeswindow.b3dni.swap(),window.b3dni.refresh()andwindow.b3dni.number, and fires ab3dni:numberevent ondocumentwhen 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
/api/public/dni/leaseCalled 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.
Request body
| Name | Type | Description |
|---|---|---|
poolRequired | string | The pool's public key, from the pool's snippet.pk_ followed by 24 hexadecimal characters |
visitorIdOptional | string | The 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 - |
paramsOptional | object | The 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 |
urlOptional | string | The landing URL. Longer is cut, not refused.up to 2000 characters |
referrerOptional | string | Where 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.
numbercarries three spellings of the same number,sessionIdis a large integer sent as a string, andttlSecondsis how long the lease lasts without a heartbeat.overflow: truemeans 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
sessionIdisnull. 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
| Status | Code | When |
|---|---|---|
| 400 | validation_error | pool is missing or is not a pool key. It is the only field that can fail this way. |
| 403 | origin_not_allowed | The 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. |
| 404 | pool_not_found | No pool has that public key. |
| 409 | pool_paused | The pool is paused. It leases nothing, and the page keeps its own number. |
| 409 | pool_empty | There is no number to lease and no usable fallback. |
| 429 | — | More 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
/api/public/dni/heartbeatCalled 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.
Request body
| Name | Type | Description |
|---|---|---|
sessionIdRequired | string | number | The sessionId the lease answered with. A number is accepted and read as its digits.1–18 digits |
visitorIdRequired | string | The 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
| Status | Code | When |
|---|---|---|
| 400 | validation_error | sessionId is missing or is not 1–18 digits, or visitorId is missing. |
| 403 | origin_not_allowed | The pool lists allowed domains and this page's host is not covered by one. |
| 404 | session_not_found | No such session — including the case where the sessionId and the visitorId do not belong together. The two are indistinguishable on purpose. |
| 429 | — | More 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. |
