Call tracking for ad campaigns
Twilio call tracking + dynamic number insertion
Tells you which advert produced which phone call, and records the ones nobody answered. For a business whose customers ring rather than fill in a form.
A feature of the Playground
This is a feature of the Vibe Coder Playground. It runs inside the Playground, with its audit record and its caveats beside it.
The mistake it removes
Three ways this is normally got wrong, all of them expensive. (1) No webhook signature validation: the URL is public, and an unvalidated voice handler will dial whatever a stranger POSTs to it, on your account. (2) Swapping the number everywhere: local ranking depends on the name/address/phone matching your Google Business Profile and every directory, so a footer swap damages the thing being measured. (3) Recording without consent: several states, Florida and California among them, require every party to agree.
What is already handled
Each of these was checked by running the code, not by reading it.
- Signature validation was tested in both directions rather than read: a correct signature is accepted, and a tampered parameter, a wrong token, an added parameter, an absent signature and an unconfigured URL are each rejected.
- The PHP and Node validators were checked against each other on the same input and produce a byte-identical digest: over https://example.com/twilio-voice with CallSid/From/To signed by the token `block-check-fixed-token`, BOTH stacks accept 1AlPCCjKBZ0/sYVkDH5Z2PfgC98= and BOTH refuse it once a parameter is tampered with — executed by the block gate wherever php is on PATH, rather than asserted here. Two implementations of one spec is exactly where they drift.
- The refusal paths were exercised, not just written: recording with an empty announcement returns 500, and a forward target equal to the dialed number returns 500 rather than looping.
- Both stacks are driven over ONE case table, registry/blocks/call-tracking-kit/test/number-cases.json, by test/call-tracking-kit.test.mjs: every row's verdict is checked against the table on the Node side and against the NODE verdict on the PHP side, so a spelling cannot be hardened on one stack and left broken on the other. That assertion is what the block shipped two different products without.
- Every .php file this block ships is passed through php -l BY THE TEST rather than claimed here — the earlier version of this line asserted it and nothing checked it.
- Eleven mutants were planted in the PHP self-forward guard while it was being written — the founding raw-digit compare, NFKC removal, an un-anchored 00/011 strip, the last-ten comparison narrowed to last-four, a separator dropped from the closed set, the extension leaking into the comparison, the E.164 cap removed, the alphabetic markers back in the extension body, the incoming To no longer format-checked, the handler restoring its own raw-digit compare, and the guard deleted outright — and each one failed a named assertion against that shared table before the file was restored. THAT IS A RECORD OF THE DAY IT WAS WRITTEN, NOT A CONTROL THAT RUNS: nothing re-plants them, because a gate that edits shipped source in order to grade it is how a half-mutated file survives a crash, and no verdict in this repository's ledger records that set. What IS re-asked on every run is what they were planted to defend — the guard and the shared case table are both still in place, and both stacks still agree over every row of it.
- vanilla/dni.js is no longer read-only: test/call-tracking-kit.test.mjs runs the shipped file in node:vm against a hand-rolled page, one row per arrival, and asserts the bucket it stores. Fifteen arrivals are driven — an empty referrer with nothing else, an empty referrer carrying each of cpc, gclid, wbraid, msclkid, fbclid, ttclid, igshid, utm_medium=organic and utm_medium=social, a referrer from your own host in another letter case with a trailing root dot, and real search, social and third-party referrers. Sixteen of those assertions FAIL against the pre-fix file, which is how the fix was shown to be one. SINCE ph105tsk89 the utm_medium table is driven too: 28 mediums, one arrival each, asserted in BOTH directions — that the eight paid-media mediums (cpc, ppc, paid, display, banner, cpm, retargeting, paid_social) still answer `paid`, and that none of the other twenty does. Beside them, a bare `?utm_source=` with no medium, an empty utm_medium, `?utm_medium=email` with no utm_source at all, `?utm_source=fb&fbclid=`, five Object.prototype names arriving as mediums (`constructor`, `toString`, `hasOwnProperty`, `valueOf`, `__proto__`), and a check that every bucket the 45 driven arrivals produce is a key in NUMBERS, so a new bucket cannot be added without becoming configurable. 29 of those 42 assertions (counted in a baseline worktree at pre-fix commit 7948a050, not counted here) FAIL against the pre-fix file. That DOM is a double. What a real browser hands the same file (a referrer left by a real link click, a real per-tab sessionStorage, a selector walk over parsed markup) is driven separately, by the browser leg in the claim below.
- What the guard HANDS ON is graded, not only what it refuses (ph105tsk52). Every row of the shared table that is forwarded declares the exact string that must appear inside <Number>; the Node side is checked by reading the emitted TwiML, the PHP side through call_tracking_dial(), and the PHP handler by a source assertion that it emits that value and not the raw constant. Six mutants were planted and all six failed a named assertion: the raw string back in <Number> on each stack, the raw string back in the callerId fallback on each stack, the extension dropped from what is dialed, and the deletion of the forwarded full-width rows. Since ph105tsk64 each forwarded row also declares its exact sendDigits value, because a letter extension no longer appears inside <Number>. Before this the block validated the NFKC-normalized form and dialed the original, so a legitimate full-width forward target was accepted with a 200 and the call silently did not connect.
- The paid-vs-social boundary is EXECUTED and not asserted in prose (ph105tsk92): the seven platform click ids this block ships are READ OFF vanilla/dni.js's own PLATFORM_CLICK_IDS list by the suite rather than restated in it — fbclid, igshid, ttclid, twclid, li_fat_id, rdt_cid, epik — each driven as its own arrival, and every one answers `paid-social`, a bucket with its own key in NUMBERS. BOTH wrong answers are refused by an assertion of their own rather than implied by the right one: not one of the seven comes out `social`, which an owner reads as organic reach they did not buy, and not one comes out `paid`, which credits an ad budget with calls a share brought in. A label the owner chose still outranks the click id in both directions — `utm_medium=organic_social` beside an fbclid is `social` and `utm_medium=paid_social` beside one is `paid` — and `paid-social` does NOT inherit the `social` tracking number, so an install that maps only `social` leaves those callers on the real number instead of ringing the line it reads as reach. Parsing the list rather than restating it is what makes a click id added later red on its first run.
- vanilla/dni.js has run in a real browser (ph0tsk596): 26 assertions (counted here: test/call-tracking-kit.browser.test.mjs) in headless Chromium load the shipped file into real pages. A real click from a google.com results page arrives with the referrer trimmed to its origin and is still `organic`; a facebook.com page gives `social` and an unrelated site `referral`; a rel="noreferrer" link and an https-to-http hop both arrive with an empty referrer and are `no-referrer`. The bucket is kept in the real sessionStorage across a second page, is not inherited by a new tab, and is `internal` from your own page with nothing stored. Only marked elements are swapped: the unmarked footer number and the JSON-LD telephone stay exactly as written, a configured value carrying markup is written as text, and a sandboxed frame whose sessionStorage throws still gets its number swapped. Only Chromium ran; in-app browsers on a phone did not, and dd.json says so.
What you still have to do
The feature cannot own your secrets, your host or your legal obligations. This is the part it deliberately does not claim.
- Serve the webhook over HTTPS at the EXACT url configured in the Twilio console, and keep TWILIO_AUTH_TOKEN in your host's environment. Signature validation is shipped and tested both directions, but it validates against the url you configure — rebuilding that url from request headers hands an attacker control of the signed string.
- Put LOG_DIR outside the served web root on your host, and state RETENTION_DAYS in the privacy page your site publishes. The log holds caller numbers (last four digits) and the block cannot see where your host serves files from.
- A spend trigger set in the Twilio console. The handlers refuse to forward a number to itself, which is the loop that runs up a bill, but Twilio bills per minute on both legs and nothing in a webhook handler can cap what your account spends.
- Confirm with a lawyer which recording-consent rules apply to your calls (they are state by state — Florida and California are all-party) and publish the disclosure. The kit refuses to record without an announcement, which is the mechanism; whether your announcement satisfies your jurisdiction is not something a block can decide.
- NOT LEGAL ADVICE. Recording law is state by state and changes. The kit makes the safe thing the default and refuses to record without an announcement, but which rules apply to your calls is a question for a lawyer.
- Consent to be RECORDED is not permission to PUBLISH. The announcement covers recording the call; it does not let you turn what a caller said into a blog post, an FAQ or an ad. If you ever feed recordings or transcripts into content, extract an anonymized question, discard the transcript, and confirm both gates with a lawyer first. The consent precondition is written out in server/services/content-channel-inputs.js (ph112tsk18).
- The status log stores only the last four digits of the caller number, on purpose. The whole number is personal data an attribution log has no need to hold, and it is in the Twilio console behind their authentication if you genuinely need it.
- CALL_FORWARD_TO must be a bare number, not free text. The self-forward guard was judged ten times and a defect was found every time, always because the value could be part number and part note; the format is now closed (optional +, ASCII digits with ( ) - . and spaces as formatting, then at most one extension introduced by x, ext, p, w, ",", ";" or "#"). Anything else returns a 500 that prints the accepted shape and names the offending character. A hyphen or a space before trailing digits is FORMATTING, so "+15558887777-123" is a fourteen-digit number, not an extension — write an extension with a separator from the set above.
- A call to the `no-referrer` tracking number tells you the visit arrived with no referrer. It does NOT tell you the caller typed your address, and reporting it as `direct` is the defect this block shipped until ph105tsk87 — measured, 100% of TikTok, Slack, Discord, WhatsApp and Mastodon traffic and 75% of Facebook Messenger traffic arrives that way. Tag your own campaigns with utm parameters and that traffic leaves the bucket, which is the only way to shrink it honestly.
- Dynamic number insertion is JavaScript. A visitor with it disabled sees the real number, which rings — the feature degrades to "not measured", never to "cannot call".
- It does not touch Google Business Profile. Put the real number there, always.
- No SMS. Two-way texting is a different consent regime (TCPA) and belongs in its own block.
- PHP without ext-intl cannot NFKC-normalize, so a forward target written in full-width digits (a paste from a non-Latin keyboard) is REFUSED as a format error on that host rather than recognized. The Node stack always normalizes. That is the fail-closed direction — calls stop connecting and you find out on the first test call, where a wrong comparison bills silently — and the test says which path the host it ran on took rather than assuming.
- What is dialed is the CLEANED number, not the string you typed: "(555) 123-4567" is emitted as "5551234567" and a full-width paste as ASCII. Formatting between the number and an extension marker goes with the rest of the formatting; a "," ";" "#" or "w" extension is kept exactly as written after normalization, and an x, ext, extension or p extension leaves <Number> and is pressed after connect as sendDigits="w<digits>" (ph105tsk64).
What lands in your project
php
- php/twilio-signature.php
- php/twilio-number.php
- php/twilio-voice.php
- php/twilio-status.php
For shared hosting — cPanel, GoDaddy, anything with PHP 8 and no Node. Upload all four beside the site, set the constants at the top of each handler, and point the Twilio number at the public URLs. twilio-signature.php is required by both handlers and is the security boundary; twilio-number.php holds the phone-number grammar, the self-forward decision AND the value that gets dialed, and twilio-voice.php requires it — do not deploy a handler without either. The grammar lives in its own file so a test can execute it: until ph105tsk37 the decision was buried in the handler beside header() and $_POST, nothing could run it, and this stack quietly kept a defect the Node stack had fixed. No composer, no dependencies, stdlib only. ext-intl is OPTIONAL: with it, full-width digits are normalized and recognized; without it they are refused as a format error, which is the fail-closed direction.
node
- node/twilio-voice.js
Serverless-style default export (Vercel-shaped). ESM, because a CommonJS module.exports in a "type": "module" repo parses cleanly and exports nothing. Config from the environment. signatureOk is exported separately so it can be tested without a live call. No dependencies — the signature is node:crypto, not the twilio SDK.
vanilla
- vanilla/dni.js
Dynamic number insertion. Swaps only elements carrying data-dni, so anything you forget to mark keeps the real number — the safe direction. Source is decided once per session, because document.referrer is empty by the time a visitor on their fourth page decides to call. A visit that arrives with NO referrer is bucketed `no-referrer`, never `direct`: an empty field is what a messaging app, an email client, a rel="noreferrer" link, a strict Referrer-Policy and an https-to-http hop all produce, and only one of them is somebody typing your name. Map that bucket to a number and dark-social callers become countable instead of invisible.
What you supply
- TWILIO_AUTH_TOKEN — required — the auth token, never the account SID. Environment for preference.
- PUBLIC_URL / TWILIO_VOICE_URL — required, and it must be the EXACT url configured in the Twilio console. Rebuilding it from request headers breaks behind any proxy and hands an attacker control of the signed string.
- FORWARD_TO / CALL_FORWARD_TO — required, and the FORMAT IS CLOSED — anything outside it is refused with a 500 rather than guessed at. Accepted: an optional leading "+", then ASCII digits 0-9, with "(", ")", "-", "." and spaces allowed as formatting; then, optionally, ONE extension introduced by exactly one of x, ext, p, w, ",", ";" or "#". Examples: +15558887777 · (555) 888-7777 · +1 555 888 7777 · +15558887777x123 · +15558887777,,1234# · (555) 888-7777 ext 4. NOT accepted: a note after the number, a tel: URI, angle brackets, a *67 service code, any other letter, digits from another script, or more than 15 digits. The extension is excluded from the self-forward comparison. An extension written with x, ext, extension or p is not dialed inside <Number>: the bare number is dialed and the extension is sent after connect as sendDigits="w<digits>" (a half-second pause, then the digits). A ",", ";", "#" or "w" suffix is dialed as written, unchanged. A "," or ";" inside a letter extension is refused. WHAT REACHES TWILIO IS THE PARSED VALUE, never the raw one: the string in <Number> (and the callerId when Twilio sends no From) is the number this grammar actually read — NFKC-normalized, formatting removed, a pause-style extension kept and a letter extension moved to sendDigits — so a target pasted in full-width digits is dialed as ASCII rather than accepted and then handed to Twilio in a form it cannot dial. A constant — never read from the request.
- RECORD / CALL_RECORD — default false. True requires a non-empty announcement or the handler refuses.
- ANNOUNCEMENT — heard by the caller before the call connects, whenever recording is on.
- LOG_DIR — default ../call-log — OUTSIDE the web root. A call log is a list of customer numbers.
- RETENTION_DAYS — default 365. Match it to the privacy policy, and change both together.
- NUMBERS in dni.js — source -> tracking number, E.164. Leave a source blank and it is not swapped. The nine sources are organic, paid, paid-social, social, email, referral, other, no-referrer and internal. `paid-social` (ph105tsk92) means a social platform's own click id was on the link — fbclid, igshid, ttclid, twclid, li_fat_id, rdt_cid, epik — and nothing else said what the visit was: that id rides on an AD and on an ordinary share alike, so the bucket says a social platform sent this and whether anybody paid for it is NOT knowable from the page. Read it as neither confirmed ad spend nor organic reach. Until ph105tsk92 those arrivals were `social`, which an owner reads as organic reach, so an untagged Facebook, Instagram, TikTok or Reddit AD was reported as reach nobody bought; answering `paid` instead would credit an ad budget with calls a share brought in, so the bucket refuses both. It empties by TAGGING and not by guessing: `utm_medium=paid_social` beside the same click id is `paid` and `utm_medium=organic_social` is `social`, because a label the owner chose is evidence and a click id the platform stapled on is not. It does NOT share the `social` number — give it one of its own or those callers keep the real number. `email` means the campaign was tagged email or newsletter — a list you own, not an ad — and until ph105tsk89 there was no such bucket: `utm_medium=email` was reported as `paid`, along with every other medium that was not literally the word `organic`, and so was a bare `?utm_source=` with no medium beside it. `other` means the visit WAS tagged but with nothing this block has a bucket for (`sms`, `affiliate`, `podcast`, `qr`, a word of your own); it is deliberately not `paid`, because calling something an ad that was not credits an ad budget with calls it never bought. `no-referrer` means the browser sent no referrer at all — mostly dark social (a messaging app, an in-app browser, a privacy setting), sometimes a typed address, and NOT knowable from the page; `internal` means they arrived from one of your own pages with nothing stored for the session, so how they first reached you is lost. A `direct` key from an install made before ph105tsk87 is still read, as `no-referrer`.
Get it
Download the Playground See pricing
A feature of the Vibe Coder Playground.
Questions
- Is Call tracking for ad campaigns audited?
- No. No full audit has been run against this block yet, which is not the same as a pass. What is verified about it is listed on this page, and what is not is listed beside it.
- What do I still have to do myself?
- Serve the webhook over HTTPS at the EXACT url configured in the Twilio console, and keep TWILIO_AUTH_TOKEN in your host's environment. Signature validation is shipped and tested both directions, but it validates against the url you configure — rebuilding that url from request headers hands an attacker control of the signed string.
- How do I get this?
- It is a feature of the Vibe Coder Playground. Download the Playground, and see the pricing page for what each plan includes.
All Playground features · Free code blocks · Learn to build from zero · The coding guide