The whole product, in the order it runs.
Self-hosted ad attribution, one payment of $997. Own the
click, the identity graph and the conversion API, with the install done with
you — one Postgres, one image, one docker compose up.
There is no account to sign up for here and nothing hosted to rent. This site is one instance of the software, and the licence is how you get another: what comes with it is a call where we stand the instance up with you, not a document that assumes it went well.
The problem
Ask Meta, Google and TikTok how many sales they produced last month and the answers add up to more sales than you made. Each platform sees only its own clicks, counts a conversion whenever one of them appears anywhere in the window, and never learns that the same buyer clicked all three. Meanwhile the browser is deleting the evidence: third-party cookies are blocked by default in Safari and Firefox, and a cookie written by a script on someone else’s domain is capped at seven days in Safari.
So the two halves of the question — who is this person across all their visits and which of their clicks deserves the credit — both need to be answered on infrastructure you control, from data you kept yourself. OpenAttrib is that infrastructure: a tracker on your own domain, an identity graph in your own Postgres, five attribution models over your own touchpoints, and a server-side push of the result back to each ad platform.
See it in five moves
Each panel below is one thing the software does, in the order it does it.
1. Three platforms, one sale
Add the platforms up and they claim 106 sales against the 62 that happened. Each one sees only its own clicks and takes full credit whenever one shows up in the window. Nobody is lying; nobody can see the other two.
2. Four strangers become one customer
Every identifier is a node, every co-occurrence an edge, and a person is a connected component. The checkout that finally supplies an email address retroactively joins the four sessions that led to it — and the edge records how they were joined, so a login and a fingerprint stay distinguishable.
3. One sale, five opinions
The same four touchpoints, split five ways, all stored. One model is a religious argument; five are a diagnosis — when first and last disagree about a campaign, that campaign opens conversations it does not close.
4. The webhook fires three times; one sale is sent
The upstream event ID is the dedup key, and the ledger row is the idempotency key: a unique constraint on (conversion, platform) means no number of retries, replays or parallel workers can double-send a sale.
5. Spend lands next to the revenue it bought
The join is anchored on spend, so a campaign that cost money and returned nothing appears as a zero-return row instead of quietly not appearing — the failure mode that makes a dashboard flattering and useless.
That is the whole product. The rest of this page is how each move is implemented, and how to run it yourself.
How it works, end to end
browser ──▶ /i ingest ──▶ identity graph ──▶ person
│ │
webhook ──▶ conversion ──────────────────────────────┤
│ │
▼ ▼
attribution ◀── eligible touchpoints ──┘
│
▼
send ledger ──▶ Meta / Google / TikTok
│
ad spend ──────────┴────────▶ campaign P&L (spend, revenue, ROAS)
1. The tracker
One script tag on your site, served from your own domain. It reads the click ID the ad platform put in the URL (gclid, wbraid, gbraid, fbclid, ttclid, msclkid), the UTM parameters, the referrer and a first-party visitor ID, and posts them to the ingest endpoint.
Why: Served first-party, the visitor ID is a cookie on your domain, so it survives the third-party blocking that kills a pixel from someone else’s. The tracker also assembles Meta’s fbc value in the exact shape Meta expects, because a value you reconstruct later from a stored fbclid is not the same string and will not match.
2. Ingest
Each hit becomes a touchpoint row: timestamp, channel, campaign, click IDs, referrer, and — if the visitor has identified themselves — the SHA-256 of their email and phone. The channel is classified once, at write time: a click ID beats a UTM, a UTM beats a referrer, and what is left is organic or direct.
Why: Click ID beats UTM because a UTM is a string someone typed into a campaign builder and a click ID is a token the ad platform’s own redirect issued. When they disagree, one of them is a typo and it is never the click ID. Classifying at write time also means a reporting query never has to reimplement the rules and get them subtly different.
3. The identity graph
Identifiers are nodes — visitor ID, hashed email, hashed phone, each click ID, a fingerprint hash, your CRM’s own ID. Two identifiers seen together get an edge. A person is a connected component of that graph, materialized so queries never have to walk it.
Why: This is what turns four anonymous sessions and one checkout into one customer with four touchpoints. Each edge records how the two identifiers were seen together, so a merge is auditable rather than a guess: an email typed into your checkout form is a fact, a browser fingerprint is a probability, and the graph keeps them distinguishable.
4. The conversion
Revenue arrives from a Stripe or WooCommerce webhook, or from your own POST. Every conversion carries the upstream system’s own event ID, and a rebill or refund carries a pointer back to the purchase that started the relationship.
Why: Using the upstream event ID as the dedup key makes replays free — a webhook delivered three times inserts one row. The parent pointer exists because a renewal eight months after the click has no useful timestamp of its own: credit is always measured from the root purchase, so month 9 of a subscription still credits the ad that sold month 1.
5. Attribution
For each conversion, take the person’s touchpoints at or before the root purchase and inside the lookback window, then split one unit of credit across them five ways: first, last, linear, time decay (7-day half-life) and position (40% to the click that found them, 40% to the one that closed). Every model is stored, every time.
Why: One model is a religious argument; five models are a diagnosis. If first and last disagree about a campaign, that campaign opens conversations it does not close, and you learned something. The maths is a pure function of timestamps — no clock, no database, no randomness — so a rerun after a late-arriving touchpoint produces exactly what a first run on the complete data would have produced.
6. The send ledger
Attributed conversions are pushed back to Meta’s Conversions API, Google Ads and TikTok Events, server-side. One ledger row per conversion per platform, claimed by a worker, retried with a doubling backoff up to eight attempts.
Why: The ledger row is the idempotency key: a unique constraint on (conversion, platform) means the same sale cannot be enqueued twice, no matter how many times the webhook fires or how many workers are running. A 4xx from a platform is usually a bad payload, not a bad moment, so it is recorded as failed instead of retried forever — the exception is a documented rate limit (Meta’s error code 4, 17, 32 or 613 on an ordinary 400, or a 429 from any of the three), which is retried like a 5xx. Sending server-side also means a conversion still reaches the platform when the browser never got the chance — ad blocker, closed tab, payment completed by phone.
7. Spend and ROAS
Spend is pulled from Meta and Google on a schedule and joined to attributed revenue by campaign and day. The P&L page shows spend, revenue and ROAS per campaign per model.
Why: Revenue without spend is a vanity number. The join is anchored on spend, so a campaign that cost money and produced nothing shows up as a zero-return row rather than quietly not appearing at all — which is the failure mode that makes a dashboard flattering and useless.
Why it is built this way
- Self-hosted, not a SaaS you send data to
- The raw material here is your customers’ emails, phone numbers and purchase history. An attribution vendor is a copy of that in someone else’s database, under someone else’s subprocessor list. Here the data never leaves the box you deploy, which also makes the GDPR answer short.
- Hashes computed at ingest, never raw PII in flight
- Email and phone are normalized and SHA-256’d at the edge, and the hash is
what gets stored and what gets sent. That is the same hash Meta and Google
want anyway, so hashing early costs nothing and means a database dump is not
a mailing list. Gmail dot-and-plus stripping is deliberately not
done:
a.b@andab@are the same inbox at Google and different people almost everywhere else, so the platforms’ own rule wins. - Two different customers are never auto-merged
- If joining two identity components would fuse two people who each have their own confirmed email address, the merge is refused and audited instead. Fusing two real customers is unrecoverable in a way that leaving them split is not — one is a bad number in a report, the other is one person’s purchase history attached to another person’s email.
- Fingerprints have a confidence floor
- Deterministic evidence — a login, a checkout, a click ID the platform itself issued — merges unconditionally. A browser fingerprint only merges at or above a threshold you set per project (0.85 by default). Probabilistic matching is useful and it is not the same kind of fact, so it is not given the same authority.
- Attribution is a derived table, not an event log
- Recomputing a conversion deletes its old credit rows and writes new ones. A late touchpoint, a corrected lookback window or a bug fix in a model can therefore be replayed over history, and the result is what a first run on the full data would have been rather than a merge of two eras.
- No queue server, no job table
- The two things that need doing are already durable rows with an obvious
“not done yet” predicate: a conversion with no attribution timestamp, and a
ledger row still pending. A worker polls those predicates and claims rows
with
for update skip locked, so several workers can run without a coordinator and without double-sending. Redis and a job table would be a second thing to operate for state Postgres already holds. - Server-rendered HTML, four npm dependencies
- The whole runtime is Postgres plus one image running two Node processes, an API and a worker; the dependencies are a web framework, a Postgres driver, a phone-number parser and a fingerprint library (used only for the optional device-match bundle). There is no front-end build, no CDN script tag and no supply chain to audit beyond that. Software you are expected to self-host has to be software you can actually keep running.
- Privacy switches are per project, not per vendor
- IP truncation and a consent gate are settings on your project. Export and erasure for one person are a single call each: erasure removes their touchpoints, calls, conversions and identifiers in one transaction and leaves an audit row behind, because “we deleted it” is a claim you may have to evidence.
The numbers that matter
| Setting | Default | Meaning |
|---|---|---|
| Lookback window | per project | How far back a touchpoint can be and still earn credit. 0 means no limit. |
| Time-decay half-life | 7 days | A touch one half-life older is worth half as much. |
| Position model edges | 40% / 40% | First and last click; the remaining 20% splits across the middle. |
| Fingerprint floor | 0.85 | Below this confidence, a fingerprint edge does not merge people. |
| Send retries | 8 attempts | Doubling backoff, capped at one hour. 4xx responses are not retried, except a platform’s own rate limit. |
| Meta event window | 7 days | Conversions older than the platform accepts are skipped, not failed. |
| Call number lease | 30 minutes | A returning visitor keeps the number they were already shown. |
Run your own instance
This site is an ordinary instance of the software, built from the same tarball your licence gives you. Nothing here is a hosted-only feature: download it, fill in three values, and you have the same thing on your own box with your data never leaving it.
curl -O https://openattrib.com/openattrib-src.tar.gz
mkdir openattrib && tar -xzf openattrib-src.tar.gz -C openattrib
cd openattrib
cp env.example .env && $EDITOR .env
docker compose up -d
The three values are a Postgres password, the domain you will serve the tracker from, and an email address for the certificate. Requirements are Docker with Compose v2, a domain you control, and ports 80 and 443. The whole runtime is Postgres plus two Node processes in one image — an API and a worker — four npm dependencies, no queue server, no cloud account. It is AGPL-3.0-or-later, so the copy you run is yours to modify; if you run a modified copy as a service for others, publish the changes.
Pointing it at your site
Once the stack is up, open /app on your own instance. The first
account you create there is the administrator — this page is the front door of
one instance, not a login for a shared service, and there is nothing here to log
in to.
- Create the first account on your instance and add your website. No SQL, no config file.
- Paste one line into your site’s
<head>. The setup page generates it for you and tells you the moment the first visit arrives. - Point Stripe or WooCommerce at the webhook URL the same page hands you, and revenue starts landing against campaigns.
<script src="https://YOUR-INSTANCE/ot.js" data-project="YOUR-PROJECT-ID"
data-endpoint="https://YOUR-INSTANCE/i" async></script>
Connecting the ad platforms is optional and additive: without credentials you still get the identity graph, the five models and the P&L from your own revenue data. Add a Meta dataset ID and an access token and the same conversions start flowing back to Meta as well, with no change to anything already recorded.
What you get
First-party tracker
About 2.5 KB gzipped, served from your own subdomain. Captures gclid, fbclid, UTMs and referrer, and builds Meta’s fbc in the shape Meta expects.
Identity graph
Emails, phones, click IDs and visitor IDs are nodes; co-occurrence is an edge; a person is a connected component. Merges are transactional.
Five attribution models
first, last, linear, time decay and position. Credits sum to 1.0 per model, and the maths is pure — a rerun is byte-identical.
Server-side fan-out
Conversions go to Meta CAPI, Google Ads and TikTok Events with one ledger row per destination, so a retry can never double-send.
Spend and ROAS
Spend is pulled from Meta and Google and joined to attributed revenue, per campaign per day, in the P&L page.
Call tracking and GDPR
Dynamic number insertion with leased numbers, plus one-call export and erase for a person by email address.
What it is not
Not a session-replay tool, not a heatmap, not a consent banner. It answers one question — which ad produced this revenue — and it stores what that answer needs and nothing else. IP truncation and a consent gate are switches on your project, off or on as you choose.
It is also not a magic fix for a broken measurement setup. If your checkout never tells the tracker who bought, no attribution system can invent the link; what this one does is make that gap visible instead of filling it with a platform’s own optimistic guess.
$997, once. Then it is yours.
A self-hosted licence with the install done with you and twelve months of updates and support. No subscription, no per-event bill, no revenue share.
- The full source
- A licence to run it
- Install help, included
- Your ad platforms, connected
- Twelve months of updates
- Your data, on your box
One payment of $997. Tell us where you will run it and an invoice follows within one business day, payable by card or bank transfer. Nothing is charged on this site and no card details reach it. [email protected] reaches a person.