Integration guide: the tag and the hourly log
Two things to build: put one script tag inside each creative, and deliver one log file per hour. We then report what your ad server billed against what was independently verified — impressions, viewability, invalid traffic, and the gap between the two.
The three values that identify you
Your account, advertiser and campaign IDs are what the two sides are matched
on, so they must be identical in the tag and in the log file. We send
you the registered values; they go in data-partner,
data-adv and data-cid, and in the
advertiser_id and campaign_id log columns.
The campaign ID must be the one your ad server already writes in its own log. If it is something else, tell us and we will register that instead. A value invented for the tag matches nothing, and the campaign reports as unverified.
The script is served from https://t.pixtru.com/v.js and sends its
measurements to https://t.pixtru.com/b. Both are public. Most accounts
carry no key or password at all — see signing.
The tag, and where it goes
The tag goes inside the creative, within the element the ad is drawn in. It measures the element it sits in, so a tag placed outside the ad, in a separate iframe, or in a tag manager measures the page instead — and its viewability figures mean nothing.
<div style="position:relative;width:970px;height:250px">
<!-- your ad markup, unchanged -->
<a href="${CLICK_URL}"><img src="https://cdn.example.com/creative.jpg" width="970" height="250"></a>
<script src="https://t.pixtru.com/v.js"
data-imp="${AUCTION_ID}"
data-partner="your_account" data-side="dsp"
data-adv="your_advertiser" data-cid="${CAMPAIGN_ID}" data-crid="${CREATIVE_ID}"
data-site="${SITE_DOMAIN}"
data-w="970" data-h="250"></script>
</div> It loads asynchronously, adds no ad-serving delay, and blocks nothing: if it fails to load, your ad renders exactly as before.
Attributes
| Attribute | Required | What it is |
|---|---|---|
data-imp | Strongly recommended | The impression ID your ad server writes in its own log — the key your log file is matched on. Leave it out and the tag makes its own ID: the impression is still measured and reported, but it can never be matched to a billed row. |
data-partner | Yes | Your account, as we registered it. Empty or unknown is rejected. |
data-side | Yes | "dsp" if you buy, "ssp" if you sell. It decides which side of the reconciliation your rows land on. |
data-cid | Yes | Campaign ID — the one your ad server already writes. A value invented for the tag matches nothing. |
data-crid | Recommended | Creative ID. It is how a report says which creative was viewable. |
data-adv | Yes | Advertiser ID, matching the log column of the same name. For the Measured report it also decides whose report an impression is filed under: left empty, it appears in no customer's report at all. |
data-site | Recommended | The page's domain, or its full address — only the host is kept. Left out or unexpanded, the tag works it out from the page it runs on. |
data-w / data-h | Yes | The size the ad actually renders at. The viewability threshold is chosen from these. |
data-pimp | If no data-imp | Your own auction ID, when your ad server cannot expose its impression ID. Matched back through the logs and reported as a partner-ID match, never mixed into the exact rate. |
Macros your ad server must replace
Where your ad server has an impression macro, data-imp must arrive
as a real impression ID, replaced at serve time. It is the key the log file is
matched on, so it has to be the same ID your log writes in imp_id.
| Your ad server | Use for data-imp |
|---|---|
| Google Ad Manager | %%PATTERN:auction_id%% |
| Xandr | ${AUCTION_ID} |
| Display & Video 360 | none available: leave data-imp out |
| Most other DSPs and SSPs | ${AUCTION_ID} |
| Your own ad server | whatever it substitutes per impression |
An unreplaced impression or campaign macro is the most common integration
failure. It is counted per partner as macro_unexpanded and visible
within minutes rather than at month end. A data-imp that is present
but still reads ${AUCTION_ID} sends nothing at all: that is what an ad
server's preview and creative review look like, and counting them would count
ads nobody served.
No impression macro — Display & Video 360, for one. Leave
data-imp out rather than filling it with something shared, like the
campaign ID: every impression would then carry one ID and count once. Without it
the tag makes a fresh ID per impression, so the Measured
report is complete — but those impressions cannot be matched to your log.
For the site, DV360's ${SOURCE_URL} is fine: a full page address is
reported by its host.
What the tag sends
One measurement when the ad renders, and one when the ad leaves the screen, the page closes, or 60 seconds pass — whichever comes first. Both carry the same impression ID, so they are one impression, not two.
The second one carries evidence, not a verdict: continuous in-view milliseconds at each pixel threshold, sample counts, how the geometry was obtained, whether the tab was in the foreground. The server decides viewability from that evidence, so when a threshold changes it is a re-query over data we already hold rather than a re-tag and a lost month.
Signing, and when it applies
Most accounts sign nothing. Your ad server computes no hash and holds no secret, and there is no server-to-server call to build. This is how the established verification tags work: what makes the numbers trustworthy is the log reconciliation below, not a signature.
An account can be switched to signed mode, and then two more attributes are
required: data-ts, your ad server's serve time in milliseconds, and
data-sig, an HMAC over those values — see the tag
reference. We will tell you which mode your account is in; unless we have
sent you a secret, it is the open one.
What the tag gives you on its own
From the first impression, the tag alone produces the Measured report:
impressions, measurable, viewable, the viewable rate of what was measurable,
and invalid traffic, per day, campaign and website. It is in the dashboard and
at GET /v1/measured, with CSV on the same path for your own
dashboards. Today is preliminary; past days are final.
What it cannot give you is the comparison. Your log shows what was billed, and the report of what went missing is the two side by side — so without the log there is no discrepancy figure, no wasted-spend figure, and no proof of what was delivered.
Hourly log delivery
Deliver through our API. There is no bucket to configure and no cloud credentials to hold: your account owner creates an API key with the deliverer role in the dashboard, which can deliver logs and nothing else. Each hour your job makes two requests — declare the file and get an upload link, then upload to it.
# 1. Declare the file. hour is UTC; md5 is base64 or hex.
curl https://app.pixtru.com/v1/org/logs/uploads \
-H "Authorization: Bearer pxk_live_..." \
-d '{"side":"dsp","hour":"2026-09-24T14","name":"impressions.parquet",
"size":1048576,"md5":"'$(openssl md5 -binary impressions.parquet | base64)'"}'
# -> {"method":"PUT","url":"https://...","headers":{"Content-MD5":"...","Content-Length":"1048576"},
# "path":"partner-logs/your_account/dsp/2026/09/24/14/impressions.parquet",
# "expires_at":"2026-09-24T15:12:00Z", ...}
# 2. Upload, with the headers you were given, before expires_at.
curl -X PUT --upload-file impressions.parquet \
-H "Content-MD5: ..." "https://..." The folder is your account, taken from the key and never from the request, so a file can only report for the account that delivered it. The link lasts 15 minutes and accepts exactly the file you declared: the size and MD5 are part of it.
| Field | Rule |
|---|---|
side | dsp or ssp. |
hour | The UTC hour the file covers, like 2026-09-24T14. Not more than an hour ahead — that is a clock or a time-zone mistake — and not older than 30 days, which would load and then expire before it could be reconciled. |
name | Letters, digits, . _ and -, ending in .parquet, .csv or .csv.gz. An hour may have several files. |
size | In bytes, up to 2 GiB. Split a larger hour into several files. |
md5 | The file's MD5, as base64 (openssl md5 -binary FILE | base64) or hex (md5sum). |
Up to 1,000 links an hour and 100 GiB a day per account, which is enough to
backfill a month of hours at once. What happened to each file — loaded, or
rejected and why — is on the Log delivery page and at
GET /v1/org/logs, from the loader's own record.
What goes in the file
One row per impression, columns by name:
| Column | Meaning |
|---|---|
imp_id | The same ID the tag sent as data-imp. |
partner_imp_id | Your own internal ID, if different. May be empty. |
ts | When the impression was served, UTC. |
advertiser_id | The advertiser. It is the access scope: a row without it is billed but invisible to every organisation, so the file is rejected. |
campaign_id | The campaign, as your ad server writes it. |
creative_id | Matches the tag's data-crid. |
site_or_app | Domain or app bundle the ad ran on. |
exchange | Where it was bought, if you have it. |
price_micros | What was charged, in millionths of a unit. |
billable | 1 if it was charged for, 0 if not. |
Practical points
- Deliver an hour once it is complete. Late is fine, and a late file is reconciled when it lands.
- To correct a file, deliver it again under the same hour and name, with the full corrected contents. The replacement supersedes the first, including rows you dropped.
- Every file you deliver, including one you later replace, is kept for 400 days from when it was written and then removed. A replacement is a new version beside the old one, never an overwrite. Nothing is deleted quietly: an administrator can remove a file only deliberately and on the record — for a file sent by mistake with data that must not be kept.
What gets a whole file rejected
A file is checked before any of it counts, and a rejection is terminal for that version: nothing from it is loaded, and the fix is to deliver the same path again. Partial loading would put a number in a report that nobody could explain.
- Any row without
imp_id. It joins to nothing. - Any row without
advertiser_id. That column is the access scope, so such a row is billed but invisible to every organisation. - An advertiser your account was not granted. Rejected whole, rather than partly loaded.
- More than 5% of rows outside the path's hour. A few at an hour boundary are normal; a majority means the wrong hour was written.
- More than 1% duplicate
imp_ids. Duplicates collapse, so their spend would quietly vanish.
Billable rows priced at zero are counted in the detail rather than rejected — they are normal in some deals and wrong in others, and that is yours to judge.
Checking it works
The endpoint always answers 204 No Content, whether a measurement was
kept or discarded. That is deliberate: a page can never be slowed or broken by
it. It also means the browser's network tab tells you the tag fired, not that
the data was accepted — we confirm acceptance from our side.
To test: load a page with the creative on it and look for a request to
t.pixtru.com/b. Leave the page open for a minute and a second request
follows. Then tell us the campaign and roughly when, and we will confirm what
arrived.
| If it was discarded | What it means |
|---|---|
macro_unexpanded | The impression or campaign ID reached us still containing ${, %% or {{ — the wrong macro for your ad server. An unexpanded site is not a rejection: it is set aside and worked out from the page. |
missing_ids | No impression ID at all. The tag always sends one — its own, if data-imp is left out — so this means something other than the tag made the request. |
unknown_partner | data-partner was empty, or is not an account we know. |
stale_ts | The measurement arrived more than 30 minutes out of step — usually a cached or replayed tag. |
missing_serve_ts | Signed accounts only: the tag carried no data-ts, so the signature could not be checked. |
bad_hmac | Signed accounts only: the signature did not match. |
Viewability
We apply the MRC display standard: at least 50% of the ad's pixels in view for one continuous second, or 30% for one second for large formats — anything 242,500 pixels or more, which a full-page takeover certainly is.
This is why data-w and data-h must be the size the ad
actually renders at. Understate them and the wrong threshold is applied.
The rule itself lives in one place in our SQL, and the reconciler, the
mismatch pass and the live view all call it, so the three cannot disagree
about what viewable means.
What we need from you
- The campaign ID your ad server writes in its log, if it is not the one we registered.
- The rendered size of each creative, in pixels.
- Which ad server serves it, so we can confirm the macro.
- Who will run the hourly upload, so your account owner can create their deliverer key.