Troubleshooting
Start with the doctor. Most problems are a line in its output.
php craft tape/destinations/doctor
php craft tape/destinations/test
In the browser, turn on Debug and watch the console, or call tape.debug(). It shows the
consent state, which destinations loaded, which scripts failed, and how many events are held
waiting for consent.
No Tape tags in the page at all
- No destination can fire.
php craft tape/destinationslists each one with its problems. The most common is an ID set to$SOME_VARthat is empty in this environment. Tape leaves that destination out rather than write a tag with a blank ID. - You are excluded. Logged in as a member of an excluded group, or browsing from an excluded IP. Try a private window on another network.
- The page is excluded by Never track these pages, or is a preview.
- Auto-injection is off and the layout does not output
tape.head(). - The response is not HTML. Tape only injects into
text/htmlresponses, and leaves afeed.rss.twigormanifest.json.twigalone. A page with no</head>gets no head tags. - A Pro destination on Lite is kept but does not fire.
The tag fires, but no conversions appear in the platform
- Google Ads needs a conversion label on each event, set on the destination. Without one Google discards the conversion.
- LinkedIn and X need a per-event conversion ID or event ID. An event without one is not sent.
- The ID belongs to another account. A browser tag cannot tell. For platforms with a
server-side API,
tape/destinations/testcan. - Consent is held.
tape.debug().heldabove zero means events are waiting for a consent category that has not been granted. - Test mode is on. Events go to the platform's test view, not its reports.
- It is a custom event on a platform that skips them. Google Ads, LinkedIn, X, Snapchat and Pinterest only count events they have an ID for. See Custom events.
Server-side calls are skipped
The event log shows skipped rows on the server channel. Server-side sends are consent-gated, and
on the server an unanswered category counts as no. Check that:
- the destination's consent category is actually granted. Look at
tape.consent.get()in the browser; - the mirror cookie (
tape_consentby default) is present on requests to the site; - the visitor had answered before the conversion. A brand-new session that converts on its first request has no answer yet.
pending rows that never change mean the queue is not running. Run php craft queue/run on a cron
or as a daemon, or turn off Send through the queue.
Cart events are not tracked
Check the Commerce section of doctor. A step marked NOT WIRED means this Commerce version
publishes none of the event names Tape knows for it. Call craft.tape.addToCart() or
removeFromCart() from a template until Tape is updated. Also check Track the Commerce funnel
automatically is on. view_item, view_cart and the checkout steps are never automatic. See
Commerce.
Refunds are not showing up
Refunds are sent server-side only, to GA4 and webhooks, on Pro. A refund is skipped if its purchase was skipped for consent, and nothing is sent if Tape has no record of the purchase, such as an order placed before Tape was installed. Google Ads never receives automatic refunds. See Refunds.
Dynamic remarketing matches no products
The product IDs Tape sends do not match your feed. Compare one item ID in Merchant Center with what Tape sends (the Debug console shows it), then change Product IDs and Product ID prefix until they match.
tape.track() does nothing
- The event name is neither a standard event nor a valid
custom name, or it is
purchaseorrefund, which are always refused. The request answers400. See JavaScript API. - The visitor is excluded, the event is switched off in
enabledEvents, or no destination sends that event. - The endpoint is throttled. See below.
Front-end endpoints are throttled
The anonymous endpoints behind tape.track() and server-side triggers act on at most 120 and
30 requests per IP address per minute. Over the limit they answer as if nothing were
configured, and Craft's log gets one warning per visitor per minute:
Tape throttled the map endpoint for one visitor after 120 requests in a minute.
No real visitor gets near those numbers. If the warning appears for ordinary traffic, Craft is not seeing your visitors' real IP addresses. Behind a load balancer, reverse proxy or CDN, every request can appear to come from the proxy, so the whole site shares one allowance. IP exclusions are affected the same way.
Craft reads the visitor's IP through its request component, configured by two general config settings:
ipHeaders: the headers Craft takes the client IP from, in order. List the header your proxy writes, such asCF-Connecting-IPfor Cloudflare orX-Forwarded-Forfor most load balancers.trustedHosts: which hosts are trusted to send forwarded headers such asX-Forwarded-For. The default trusts any host. Narrow it to your proxy's address ranges so a visitor cannot send a forwarded header of their own and appear to be somebody else.
// config/general.php
use craft\config\GeneralConfig;
return GeneralConfig::create()
// the header your proxy or CDN puts the visitor's IP in
->ipHeaders(['X-Forwarded-For'])
// only believe forwarded headers from the proxy itself
->trustedHosts(['10.0.0.0/8']);
Both can be set from the environment instead, as CRAFT_IP_HEADERS and CRAFT_TRUSTED_HOSTS. Use
the address ranges your host or CDN publishes. Afterwards, check that Craft sees real addresses: an
entry for your own IP in Exclude these addresses should stop Tape tracking you.
Webhook signatures fail to verify
Almost always the receiver is computing the HMAC over a re-encoded body. A framework parses the
JSON, then the code serialises it again and signs that. The bytes differ even though the data is
the same, most often in escaped slashes or Unicode. Sign the raw request body exactly as received,
before any parsing. In Express that means express.raw() rather than express.json() on that
route. The header is X-Tape-Signature: sha256=<hex>. See
Webhooks for working PHP and Node examples.
Content-Security-Policy
- Tape writes two small inline scripts into
<head>: the Consent Mode defaults and its configuration object. It also loads its runtime from Craft's published resources. Yourscript-srchas to allow both, and every vendor domain your destinations load from (googletagmanager.com,connect.facebook.netand so on). Theirconnect-srcandimg-srchosts need allowing too. - A custom consent expression needs
'unsafe-eval'. It is evaluated withnew Function, which a strict CSP blocks. The symptom is that consent never arrives and every non-Google destination stays held. Use a named CMP integration instead, or calltape.consent.update()from your banner's own code. Neither needseval. See the custom expression.
Conversions are counted twice
Tape sends the browser tag and the server call with the same event ID, and the platform deduplicates on it. Double counting usually means a second, non-Tape copy of the same pixel: a hard-coded snippet in the layout, a GTM tag, or another plugin. Remove the other one. Tape already refuses a second purchase for the same order in one response.
The accuracy report reads low
That is what it is for. Turn on server-side sending for
every destination that supports it, schedule
recovery, and check doctor's list of
destinations that cannot be recovered.
The accuracy screen shows an upgrade note
The accuracy report is a Pro feature. On Lite the ledger keeps recording, so the report fills in from the first conversion once the site is on Pro. See Editions.
The destination screens are read-only
allowAdminChanges is off on this environment. Destinations and triggers are project config, so
make the change in development and deploy it.
Getting help
Email justin@justinholt.com with the output of
php craft tape/destinations/doctor.