Skip to content

Prosper202 1.9.76

Choose a tag to compare

@github-actions github-actions released this 07 Oct 10:55
· 755 commits to master since this release
40556df

⚠️ Back up the database before you upgrade

An upgrade changes the database in place and there is no downgrade: restoring a backup taken before the upgrade is the only way back. From 1.9.76, putting the old files back on an upgraded database leaves an install that can no longer record conversions.

Take a full mysqldump (or your host's snapshot) of the Prosper202 database after the site stops taking traffic, then upgrade through 202-config/upgrade.php or the 1-Click Upgrade button. On a large install the upgrade page can outlast a proxy's time limit (a million conversions takes one to two minutes). The upgrade keeps running on the server, so wait a few minutes and reload the page. Step by step: Upgrading Prosper202.

Download

Download prosper202-1.9.76.zip below, not GitHub's "Source code" archives. The zip bundles vendor/ and the p202 CLI binaries for Linux, macOS and Windows (amd64 and arm64), so a server needs no Composer and no Go.

Requirements: PHP 8.3+, and MySQL 8.0+ or MariaDB 10.6+. The installer and the upgrade page refuse older versions.

What changed in 1.9.76

  • New: p202 report losers and p202 report winners take --payout, your revenue per conversion (an average order value). Each row's break-even CPC is the payout times that row's own conversion rate. Without a break-even, losers only listed rows with spend and no conversions, so a traffic source that made some sales at a loss was WATCH and never listed, and the starter check never saw it. The only payout input was --aff_campaign_id, which also filters the report to that campaign and so turns the attribution check off. --payout adds no filter, so those sources come back CUT, or TEST when they start sales. It values every sale the commands report: total_net is conversions × payout less cost (so winners lists a row that is profitable at your payout even when the campaign's recorded income puts it at a loss, and each row says the payout it was valued at), and the first-touch ROI of the attribution check is attributed conversions × payout against cost. The classic report counts a converted click once however many sales it had (accumulate-mode campaigns, repeating goals), so the check also reads the Last touch model, whose credits count each row's sales, and values the rows it reads per sale: a loser that covers its break-even once every sale counts is dropped (a note says how many), and a source that is profitable only through repeat sales is listed as a winner, also on an account with no First touch model (the closer check is then skipped, but sales are still counted). Without a Last touch model, or when the check can't run, sales are counted per converted click and a note says so. --max-cpc still sets the break-even when both are given, and an explicit --payout of 0, a negative or non-finite one, or one over 1,000,000,000 is refused with exit code 1 before any request
  • Fixed: GET /reports/breakdown ordered rows only by the sort column, so rows tied on it (very common: many campaigns with 0 clicks) came back in no defined order, and a client paging with offset (p202 campaign list --with-stats, p202 analytics --split-at) could skip some rows and read others twice. Ties now break on the dimension's id, ascending. The same gap is closed in the LTV breakdowns (campaign, ppc_account, landing_page, product), the ABM account breakdown and the forecast-events list, which pages by date
  • Fixed: GET /reports/timeseries returned at most 2000 buckets and said nothing when it cut, so a long window at --interval day or hour came back exactly 2000 rows short of its newest buckets with no way to tell. The response now carries truncated (true when more buckets matched) and limit (2000) beside data and interval; buckets stay oldest first, so the cut drops the newest. p202 report timeseries prints a warning to stderr naming the last bucket returned, with a hint to narrow --time_from/--time_to or use a coarser --interval, and warns the same way ("may be missing") on 2000 buckets from a server that predates the flag
  • Fixed: click_bot was never set. A click counted as a bot only when ua-parser named its family "Bot", which it never does (it says "Spider"), so every click recorded click_bot = 0, AdsBot-Google and HeadlessChrome included. dl.php, rtr.php and both record pages now share one detector: device type 4, ua-parser's Spider family, or a list of crawler, link-preview, headless-browser, automation, uptime-monitor and HTTP-library agents. Bots are still redirected as before. A bot click is also click_filtered = 1, as the old rule intended, so it leaves the "Real clicks" view and attribution's clicks, cost and journeys; "All clicks" is unchanged
  • Fixed: The fallback redirect a click uses while MySQL is down (dl.php, lp.php and off.php) was cached once with no expiry, so after an offer or landing page URL changed, an outage sent traffic to the URL from that tracker's first click. The cached URL is now rewritten whenever a click sees it differ, with no extra database query
  • Changed: POST /conversions says when it recorded nothing: a duplicate (the click already has that transaction id) carries duplicate: true beside idempotent_replay, still with 201. A transaction id whose conversion was deleted is refused with 409 and {conv_id, click_id, deleted: true} instead of a 500, and a failed write no longer spends its Idempotency-Key. p202 conversion create notes on stderr when the server answered with an existing conversion or a replay, and p202 conversion import uses the flag (keeping its pre-read for older servers)
  • Fixed: p202 campaign list --with-stats could print nothing and exit 0: a NaN or infinite stat failed JSON encoding and the error was dropped. Stats rows are read strictly (a null sum reads as 0; a missing field or NaN is an error), toFloat never returns a non-finite value, and every JSON encoding error in the CLI is now reported. p202 report breakdown --min-clicks/--having no longer returns unfiltered rows when the response can't be parsed: it fails as a server error (exit code 4), keeping the parse error, with a hint to look at the raw response and run p202 system health, since the flags aren't the problem. p202 dashboard and report summary --all-profiles refuse a NaN or infinite metric with an error naming the profile and field, instead of a bare JSON encoding error
  • New: GET /attribution/reports/breakdown takes cohort=click to count credits and assisted conversions by the date of the click they land on instead of the sale's date. Each row is then what its own clicks in the range earned, whenever they converted, against those clicks' cost: the population the classic reports count, so last-touch credit in the click cohort is the classic row's revenue. The default, cohort=conversion, is unchanged (the sales made in the range, credited to clicks of any age, as the Attribution page's Report shows), and meta.cohort says which was read. A click cohort report is always computed in full, since the report rollup is kept by conversion hour. p202 attribution breakdown --cohort click|conversion and the PHP CLI's attribution:breakdown --cohort pass it; an older server refuses it and the Go CLI says to drop the flag
  • New: GET /attribution/reports/breakdown takes keys, 1 to 1000 comma-separated row keys, and returns only those rows (in the report's order; meta.groups counts the matches, totals still cover the whole report). Every request computes the whole report and offset only slices it, so a reader that needs a few rows asks for them by key: one computation instead of one per page. p202 attribution breakdown --keys and the PHP CLI's attribution:breakdown --keys pass it. p202 report losers/winners read their attribution check this way, after the classic report, for the rows they list (1000 keys a request, nothing when no row is listed), so a keyword report with 10,000 attribution rows is computed once rather than ten times; a server without keys is paged through as before. When the Go CLI's attribution breakdown is refused several unknown flags at once, its hint names all of them
  • New: GET /attribution/reports/breakdown takes offset (a whole number, default 0) to read past the first 1000 rows. The report sorts by attributed revenue and was capped at 1000 rows with no way past them, so on a dimension with more values than that (keywords) the rows with spend and little revenue, the ones a search for waste needs, could not be read. Rows keep the report's order, meta.offset echoes it and meta.groups is the total; totals cover every group on every page. The PHP CLI's attribution:breakdown takes --offset
  • Changed: p202 report winners flags closers. It ranked from the classic last-click report, so sources that close sales other sources start (retargeting, brand search and email often do) looked like the best rows to scale. Each SCALE row is now checked under a first-touch model for the same dimension and range, and a row that loses money there comes back as CLOSER, with its first-touch ROI and assists and a reason to check what feeds it before adding budget. It shares the losers check's dimensions, paging, filter rule and notes; without a First touch model it is skipped with a note. --first-touch-model and --no-attribution-check work as on losers
  • New: p202 attribution breakdown --offset <n> pages past --limit (rows come in attributed-revenue order; meta.groups is the total). A server from before attribution paging refuses it, and the CLI says so
  • Changed: p202 report losers no longer lists sources that start sales as rows to cut. It read only the classic last-click report, so a traffic source that brings buyers in and rarely gets the final click (negative ROI there) came back as CUT. Each CUT row is now checked against the attribution report for the same dimension and range, and a row that starts sales is returned as TEST with a reason to test a cut before making it: its first-touch ROI is 0% or better, or it had a click in at least --min-assists sales (default 1; 0 turns the rule off) that another row closed. Every checked row carries assisted_conversions, and first_touch_roi when there is a First touch model. The check covers campaign, ppc_account, landing_page, keyword and country; ROI comes from the first active First touch model or --first-touch-model, and without one rows are checked on assists only, with a note to add one. It reads the attribution rows for the rows it lists, by key (on a server without keys it pages through every row; on one without offset it keeps the first page and notes how many were checked), and notes when the pre-upgrade backfill is still running. An entity filter other than the breakdown itself skips the check, since the attribution report is account-wide and its numbers wouldn't match, and so does a mixed --period plus --time_from/--time_to (the classic report uses their overlap; the attribution report takes one range). Attribution is read over the classic report's window: with no range given that is all time, as the classic report runs, not the attribution report's 30-day default. --first-touch-model must be a positive id naming an active first_touch model, checked before any report (and the campaign payout) is read, even when the check would then be skipped. A bound of 0 (--time_from 0, --time_to 0) means unset, as the classic report treats it. For a bounded range the check reads the attribution report's click cohort (cohort=click): the credit and assists that land on clicks made in the range, whenever they converted, which is what the classic rows count. A server from before the click cohort refuses it; then the check reads by sale date instead, with a note that clicks near the range's edges can differ. A First touch model still being recomputed is not used for ROI (losers falls back to assists, winners skips the closer check), since its credits are absent or stale until the worker finishes. Only a server's refusal of offset keeps a partial read, and then rows past it are marked attribution_checked: false; any other failure discards the check. When it can't run, the classic losers are still listed with a note on stderr. --no-attribution-check turns it off
  • Fixed: The hourly cron tier ran every minute, and the per-minute tier's already-ran check never matched. Development builds after 1.9.55 recorded the two tiers as 'hourly' and 'second' (1.9.55 wrote 'hour' and 'secon'), which cronjob_type, a char(5), stores as 'hourl' and 'secon', so each check looked for a name that could not be stored: on a live instance two runs in the same minute both ran the per-minute and hourly tiers. It records 1.9.55's 'hour' and 'secon' again, and the hourly check also accepts the 'hourl' rows left on the day of the upgrade. CronjobTypesFitTheColumnTest holds every type name the scheduler writes or checks to the column's width
  • Fixed: The 1-click upgrade page's "What changed" list compared version numbers as text, so "1.9.8" counted as newer than "1.9.76" and the 1.9.8 and 1.9.9 notes were shown as new on every install (since 1.8.4). It uses version_compare(), and a PHPStan rule (ForbidVersionRelationalCompareRule, CLAUDE.md #23) reports a relational operator on any value named as a version
  • Changed: Both 1-click upgrade pages show the upgrade page's own warning above their button — back up the database, the upgrade is one-way, and restoring that backup is the only way back — instead of a line of their own that read as a precaution. One function renders it for all three pages, and UpgradeBackupWarningTest fails when a page that starts an upgrade does not show it
  • Fixed: p202 --help listed event as an alias of forecast-event, but p202 event is the web-events command, so p202 event list failed as an unknown command. The alias is gone, and a test fails when any alias does not reach its own command
  • Fixed: p202 search compare before and after a date did not find analytics --split-at; analytics gains a description and examples that say what --split-at does
  • New: p202 conversion import <file> records an affiliate network's conversion export (CSV with a header row, or a JSON array) against the clicks it names, for an install whose postbacks were never wired. Columns are found from common headers (subid, sub_id, aff_sub, sub1, click_id, s2; payout, commission, amount, revenue; transaction_id, order_id, txid; date, conversion_date, created_at) and reported, or named with --subid-column, --payout-column, --txid-column and --time-column; times are unix seconds or milliseconds, YYYYMMDD, ISO 8601 or --time-format in --timezone. --dry-run classifies every row (ready, invalid with its reason, duplicate_in_file) and sends nothing. Applying reads each click's conversions first, so a transaction id already on the click, or an id-less row on a click that has converted, is a duplicate and is not sent, and posts the rest with a per-row Idempotency-Key: a re-run records nothing twice, even after the server's 24-hour idempotency window. Confirmed unless --force; --staged records proposals; a failed row exits 5 with every row shown
  • New: p202 analytics --group-by <dimension> --split-at <YYYY-MM-DD|unix> compares before and after a moment: one row per value with clicks, conversions and revenue before, after, the change and the percent change, and each side's per-day rate, so two windows of different lengths compare fairly. The window is --period last7|last30|last90, --days or --time_from/--time_to (default last90); rows rank by the absolute change in clicks, or by --sort conversions|revenue, with the _per_day suffix ranking by the change in the daily rate
  • New: p202 system health checks the instance's TLS certificate first, on its own connection, and reports tls_status (ok; expiring, within --cert-warn-days, default 21; expired; hostname_mismatch; unknown_authority; invalid; unreachable; not_used for an http URL) with its expiry date, days left and issuer. Any status but ok or not_used exits 5 with a hint naming the fix, and an expired certificate is reported as that rather than as the network error the API call behind it then fails with
  • Changed: p202 system cron prints a summary — each job type's row count and last run, and the last execution with its age — instead of every row of 202_cronjobs (230 KB on one real install). A last execution older than five minutes, or none at all, exits 5 with the crontab line as the hint; --raw prints the rows as before. Types are labelled by tier: hour is the hourly tier and secon the per-minute one, which records a row every minute by design; hourl, a server's six-letter hourly cut to the char(5) column, is labelled truncated, with a note past 100 rows
  • New: When an AI agent runs p202 — recognised by an environment marker the agent sets (AI_AGENT, CLAUDECODE, GEMINI_CLI, CODEX_SANDBOX, CODEX_SANDBOX_NETWORK_DISABLED, CODEX_THREAD_ID or CURSOR_AGENT) — commands print compact one-line JSON and errors print the JSON envelope, with no --json needed. People at a terminal and existing scripts keep tables: a format flag wins (--json, --ndjson, --csv, -q, or the new --table), then P202_OUTPUT, then the profile's p202 config set-default output.format, and p202 config show says which format is in use and why. Output asked for with --json is unchanged
  • New: p202 search <words> finds the command for a task, offline, ranking commands, flags and the values flags accept, and says plainly when nothing is a good match; p202 commands --json lists every command and flag — type, default, whether it is required, and its allowed values — in one call. p202 --help and the hint on an unknown command or flag point at both
  • Changed: Every Go CLI flag that takes a fixed set of values lists them in its help, and a value outside the set is refused before any request as --<flag> must be one of: <values> (aliases: …); got "<value>" (exit 1). The report dimension flags gain region, which the server served and the CLI refused, and ask the server for its list before refusing a value the built-in list lacks
  • New: /capabilities advertises features.report_breakdowns, the breakdown dimensions the server serves, read from the same list the reports endpoint validates against
  • Fixed: A mistyped subcommand under a group (p202 campaign lsit) printed the group's help and exited 0, which reads as success; it is now refused as an unknown command, with a suggestion, exit 1
  • New: p202 campaign check-urls finds dead offer URLs without sending a click. Each unique host is checked once — DNS lookup, TCP connect and, for https, a verified TLS handshake, closed right after — and every offer URL slot in scope (--url-contains, --slot, --ids, --aff-network-id) gets a row: ok, invalid_url, dns_failed, connect_failed, timeout or tls_failed. --http also sends one HEAD per URL, following no redirect, after a warning that a network may count it as a click and a confirmation. Any status but ok exits 5
  • New: p202 campaign list --with-stats adds clicks, leads, income, cost and net to every campaign for --period (today, yesterday, last7, last30, last90; default last30) or --days N, with zeros for a campaign that had no traffic; --min-clicks N keeps the campaigns with at least N clicks
  • New: p202 campaign list --url-contains <text> finds every campaign with an offer URL (any of its five slots) containing the text, case-insensitively, across every page, and p202 landing-page list --url-contains does the same over landing page and leave-behind URLs
  • New: p202 campaign replace-url --match <text> --with <text>, or --set <url> (which fills {id} and {slug} per campaign), rewrites matching offer URLs in bulk. --dry-run lists campaign, slot, old and new URL; otherwise the list is confirmed (--force skips), --staged records proposals, and each campaign gets one update carrying only its changed slots. A run that changes anything saves an undo manifest under ~/.p202/undo/ and prints the command that reverses it; --undo <file> restores only the slots that still hold that run's new URL and reports the rest as skipped
  • Changed: The release zip ships only what a server needs. build/release-manifest.php decides, fail-closed: the test suites (except the two fixtures the bundled CLI and SDKs read), the PHPStan rules, the messaging mock server, Go sources, Docker files and contributor notes are left out. vendor/ is installed from the committed composer.lock, resolved for PHP 8.3, so the zip carries the dependency set CI tested instead of whatever resolved on the day of the build. Before zipping, the build checks the tree: nothing excluded remains, vendor/ is exactly the locked runtime set, every namespaced class the shipped code names resolves, every shipped file is at its exact path, the six Go binaries are present, and nothing is group- or world-writable. It also refuses to build on a case-insensitive disk (macOS's default), where tracking202/Redirect/ and tracking202/redirect/ merge and every click endpoint would be served as a 404 on Linux. RELEASING.md describes the process
  • Changed: Every GitHub Actions step is pinned to a commit SHA, and a check refuses a floating tag; Dependabot proposes the updates. The release job builds with the runner image's own PHP 8.3 and Composer instead of a third-party setup action
  • Changed: The header logo follows the page's light or dark theme. It is served from ads.tracking202.com and cannot read the page's theme, so the page tells it on load and on every theme change
  • New: Android installs record their click-to-install time — Google's install-begin time minus the click's — and flag the two tails against the app's ctit_min_seconds (default 10: faster is click injection's mark) and ctit_max_seconds (default 86400, one day: slower is click spamming's). A flag marks an install; it never refuses one. The install carries ctit_seconds and ctit_flag (GET /apps/{id}/installs?ctit_flag=, p202 app install list --ctit-flag), and the Android report counts ctit_measured, ctit_short and ctit_long, groups by ctit-flag and filters by ctit_flag (Analyze › Mobile Apps shows them in a Fraud signals panel)
  • New: A per-app install cap on the public Android intake: past the registration's install_cap_per_minute (default 300) POST /apps/installs answers 429 with Retry-After and stores and counts nothing; the SDK keeps the install and retries. Beside the per-peer limit, keyed on the registration the app token resolves to. Both caps count over fixed one-minute windows (a burst straddling a window boundary can reach up to twice the cap) and per web host unless the state directory is shared (N hosts each keeping their own enforce up to N× the cap); a request that was admitted and then recorded nothing (a 503, a 500, a concurrent duplicate, a refused batch) is refunded
  • New: A per-install event cap: past the app's event_cap_per_minute (default 200 events a minute for one install, at least 100) POST /apps/installs/{install_uuid}/events answers 429 with Retry-After and stores none of the batch. Only newly stored events are charged: duplicates cost nothing, so a replayed batch is answered even at the cap; a body carrying only a customer id costs one
  • New: Goals reached implausibly fast. An install's goal reached sooner after the install than the app's fast_goal_seconds (default 5; the install itself never counts) is flagged; the Android report counts fast_goals and filters fast_goals=1. Under fast_goal_policy count (the default) a flagged goal still pays and is sent; under hold it is recorded and flagged (value_note held_too_fast) and never paid or sent. The decision is made when the outcome is first written and stands through re-evaluation, replay and credit changes: switching hold to count pays only goals reached afterwards, and count to hold un-pays nothing. All six limits are Android registration settings (PUT /apps/{id}, p202 app update --ctit-min-seconds … --fast-goal-policy, Setup › Mobile Apps › Advanced), and a stored value the server cannot read is read as the one that trusts least and named, the caps answering 503
  • Changed: The upgrade page says, above its button, that the upgrade is one-way and a database backup is required: restoring the backup is the only way back, because the upgrade makes 202_conversion_logs.dedupe_key NOT NULL and the version you upgrade from can no longer record conversions against the database. RELEASING.md says the same
  • New: Goals. A goal is a named outcome — an install, an event (with conditions on its properties if you like), the Nth occurrence, a running sum, a step after another goal, within a window — and what reaching it is worth. A goal belongs to a campaign, an app registration or the account, and web campaigns and both app platforms share the same goals. Every edit is a new version and a conversion keeps the version that produced it; re-evaluation previews, then applies, a version to past clicks and installs. A campaign pays for a goal at its own payout, and an untrusted value (revenue reported by p202.track(), say) is recorded but never paid. Setup › Campaigns gains a Goals panel, Setup › Mobile Apps edits an app's goals with the same form, and /api/v3/goals (scope area goals) and p202 goal … do the rest (documentation/api/22-goals.md)
  • New: Web events. What a click goes on to do — sign up, buy, buy again — reaches its campaign's goals three ways: event= (with optional event_id, event_props and amount) on every conversion endpoint (gpb.php, gpx.php, upx.php, pb.php, px.php); POST /api/v3/events (p202 event send); and p202.track() on a landing page, which is recorded but never paid. A funnel that needed one campaign copy per step is one campaign with a goal per step. Existing setups are unchanged: a request without event=, or with event= on a campaign that has no goal, is the plain conversion it always was. A reached goal tells the traffic source once, with [[p202_goal]], [[p202_goal_id]] and [[p202_goal_value]] beside [[payout]] and [[transactionid]] (documentation/api/23-events.md)
  • New: The conversion ledger. Every amount on a click is a conversion row that says what produced it — pixel, postback, universal pixel, the API (naming the key by a digest, never the key), a subid or revenue upload, ClickBank, an app install or a goal — and the click's value is derived from its rows under the campaign's new "When a click converts more than once" setting: Keep the latest (payout_mode replace, the default, today's behaviour) or Add them up (accumulate, which pays every step of a funnel of goals). The revenue CSV upload writes a row per line instead of one total per subid, and the legacy px.php, pb.php and ClickBank (cb202.php) endpoints record a conversion through the shared writer instead of only flagging the click or overwriting its payout with the order total
  • New: A click's value is explained row by row — every conversion on it, counted or not and why not (deleted, unpaid, superseded, a reversal with nothing to net), and what produced it — in GET /clicks/{id}/conversions, p202 click conversions <id> and a breakdown opened from a converted click's row on Visitors and Spy. GET /conversions and p202 conversion list filter by click (--click-id), source and goal, and Group Overview gains a Goal / source level
  • Fixed: Group Overview's Transaction ID level added a click's whole income once per transaction, so a click with three transactions was counted three times; it now sums the rows that count
  • Changed: Without a transaction id, the global postback (gpb.php) and the universal pixel (upx.php) convert a click once, as the global pixel (gpx.php) does, so a network's retry or a reloaded page is a duplicate rather than another conversion. Send a transaction id to record several conversions on one click
  • New: Visitor identity, the journeys multi-touch attribution is built from, uses first-party signals only — never IP addresses, user agents or fingerprinting: the p202vid cookie the tracking domain sets on every redirect, the p202lpid id landing.php keeps in the landing page's own storage, and a customer id your own server signs with the account's linking key (cust and cust_sig on links, pixels and postbacks; p202 user identity-key get). Only hashes are stored, and none is passed on to an offer. p202_consent=0 on a link, p202.consent(false) on a page, or a campaign's identity_signals set to 0 turns it all off (documentation/features/visitor-identity.md)
  • Changed: Multi-touch attribution is rebuilt on the conversion ledger and visitor identity. A conversion's value, net of its reversals, is spread over that visitor's own clicks across campaigns under a last-touch, first-touch, linear, time-decay or position-based model with a lookback of 1 to 365 days (default 30), and each conversion's credits add up exactly to its counted amount; bot clicks are left out of journeys. The Attribution page is rebuilt on the new UI: Report (with a second model side by side and a CSV of what is shown), Journeys, a journey drill-down, Models (add, edit, switch off, make default; the default cannot be deleted) and Exports, which run now or at a set time, write CSV, and can POST it to a webhook signed with HMAC-SHA256 that refuses non-https URLs and private, link-local and cloud-metadata addresses (P202_WEBHOOK_ALLOW_NETWORKS admits an internal receiver). p202 attribution export … and bin/p202's attribution:export:* drive the same jobs
  • New: Android SDK (sdk/android-attribution/): Kotlin, minSdk 21, with Google's install referrer library as its one dependency. It reports the install, the events after it and the signed customer id, and an optional module supplies Play Integrity tokens for a registration that asks for them; it reads no advertising id and adds no permission beyond INTERNET (documentation/api/25-android-sdk.md)
  • Changed: The P202Attribution iOS SDK evaluates on the device the goals its SKAN encodings name — the server's evaluator ported to Swift and run against the same cross-language test vectors — and sets the highest conversion value the reached goals map to. It is configured with configure(endpoint:appToken:) (the schema token is gone) and gains setCustomerId(_:signature:type:) for the signed customer id
  • Changed: SKAN encodings name goals instead of event names, and they are versioned. A device encodes with the schema it last fetched and Apple can deliver the postback weeks later, so an edit keeps the meaning it replaced, and the report decodes each postback under every meaning its value had in the 48 days before it arrived: one meaning decodes it, and meanings that disagree count it as ambiguous_encoding, credited to no goal
  • Changed: The sign-in, password reset, license-key, 404 and error pages, and the setup wizard, installer and upgrader, render on the same Bootstrap 5.3 shell as the rest of the app. TV202, Hot Deals and the App Store show their feeds as rows, and no remote markup reaches a page. 202-Mobile is retired: its addresses redirect to the responsive pages (Campaign Overview carries its mini stats), and the Account home drops the Mobile202 tile
  • Fixed: The setup wizard rewrote 202-config.php on any POST to its second step, on an installed instance too, so one unauthenticated request naming another database server could repoint an install. It now refuses once the configuration it finds answers with an account (and when it cannot tell), except to bring a legacy-format file up to the current format with every setting carried over; its form carries a session token, and its session cookie is Secure behind an HTTPS proxy. The license-key page saved the install's key for anyone who posted one; it now requires the session token
  • Fixed: Get Links and the redirector accepted another account's ids. Saving a tracking link stored a posted campaign, traffic source, traffic source account, text ad or redirector id without checking its owner, so another account's records could be attached to a tracker and their names read back through Slack notices; saving redirector rules could re-parent, move or delete another account's rules, redirects and criteria named by id. Every id is now checked against the signed-in account before anything is written, and editing or copying another account's campaign by id opens the Add form instead
  • Fixed: The ClickServer switch checked sign-in and the session token but not the access_to_clickservers permission, and used the API key and ClickServer id as posted; it now requires the permission, uses the account's own stored key and accepts only a domain the licence service lists under that key, and the ClickServer page no longer hands the key to the browser. The DNI actions that act for the account at my.tracking202.com (request offer access, set up an offer) ran on a bare GET; they now require a POST with the session token, as does saving report filters (set_user_prefs.php)
  • Fixed: The 1-click upgrade pages require the access_to_settings permission, as Settings does, and all three updaters write only inside the install and refuse a symlink, a dangling one included
  • Fixed: Account writes that could half-succeed or report the wrong outcome: User Management wrote a user, their role and their preferences separately, so a failed role write left a live account with a password and no role; the profile form could save the email and timezone while saying nothing had changed; and a currency change stored a payout of 0 for every campaign when the exchange-rate service could not be reached. Each is now one transaction, the currency change fetches the rates first and refuses a rate the service did not give, and the API-key and app-key changes say when a key was not saved instead of reporting success
  • Fixed: Overview, Visitors, Spy and the Analyze reports draw, page and download the view in their own URL. They stored their filters in one per-user row that later requests read, so a second tab with other filters changed what the first tab's next page and its Download to Excel returned; the stored filters are now only the first-visit default, and page links carry the view
  • Fixed: The region, ISP, browser and platform filter menus listed values from every account's clicks on the install (in a reproduction, 5,000 ISPs only another account's clicks had used put 5,001 options and 253 KB on one page); they list what this account's clicks carried in the report's window, busiest first, at most 300. A date range whose start is after its end is refused with a sentence instead of being stored
  • Fixed: Update CPC could change more clicks than its check counted: with today in the window, clicks recorded between the check and the confirm were updated too. The confirm now changes only the clicks the check counted, and shows the check again when their number has changed
  • Fixed: The password reset pages (202-lost-pass.php, 202-pass-reset.php) died on every request; they work again. Sign-in keeps the username after a refused attempt
  • Changed: The Go CLI refuses a string flag set explicitly to an empty value, which most commands read as "not given" (p202 conversion list --source "" listed every conversion as though filtered), and refuses JSON input — event and goal files, --definition, --props, app verify — with anything after its first value, which was dropped in silence
  • Fixed: Upgrading from 1.9.55 no longer clears the transaction id of conversions whose id differs from another on the same click only in letter case (Tx-1 and TX-1 are two orders): the 1.9.61 step compared ids case-insensitively and kept only the first — 8,249 of 12,500 such rows in a measured 1M-conversion database. It now makes the column exact first and merges only identical ids
  • Fixed: Only one upgrade of a database runs at a time. The upgrade page answers only when the upgrade finishes (at 1M conversions 57 s on MariaDB, 91 s on MySQL 8), so behind a proxy's time limit it showed a timeout while the upgrade carried on, and reloading ran a second upgrade alongside the first; the second now changes nothing and says an upgrade is already running. The page, the 1-click upgrades and the in-app updater report success only when the database reached the new version, and every one keeps running when the browser gives up. The upgrade page and RELEASING.md tell large installs that a timeout needs nothing redone
  • Changed: The upgrade fills in the conversion ledger's columns in ranges of 50,000 conversions, so a change to an old conversion during the upgrade waits about a second instead of the whole backfill (47.5 s at 1M on MySQL 8, near its 50 s lock timeout)
  • Fixed: Conversions recorded before the upgrade reach multi-touch attribution. The upgrade only marks the clicks it found (one indexed query, whatever the table's size); the attribution worker, every minute from 202-cronjobs/index.php, walks them in bounded chunks and brings each pre-upgrade lead click in at the value it held, queued for attribution like any conversion, so reports over dates before the upgrade fill in instead of staying empty. It resumes where it stopped and never counts a click twice; while it runs, the Attribution page, GET /attribution/queue and the attribution reports' meta.backfill say how far it is
  • Fixed: Deleting a conversion — from the API, the delete/clear subid pages, or a goal or app-install conversion an operator removes — now settles the traffic-source postbacks queued for it as the goals engine does for an outcome it retires: one not yet sent is cancelled, one that may have gone out gets a retraction (sent to the correction URL where one is set). A pending postback used to go out for a conversion the ledger no longer counted. DELETE /conversions/{id}?dry_run=1 says how many of each
  • Fixed: The postback endpoint (gpb.php) and the universal pixel (upx.php) queue the traffic source's server postbacks with the conversion, in its transaction, and send them from the notification outbox, which retries with backoff. They were sent inline after the commit and only while the conversion was new, so a send that failed after the commit was lost for good: the network's retry was a duplicate and sent nothing
  • Fixed: The Android app report's revenue is what the ledger kept: an install goal whose conversion was deleted or reversed still counts in the funnel, but no longer adds its value to revenue, in every grouping and the totals, matching the click's payout and multi-touch attribution
  • Fixed: The /apps API asks for the role permissions the Mobile Apps pages do. Registering, changing or deleting an app, its SKAN encodings, its app token and its Play Integrity credential need manage_attribution_models; the report, postbacks, installs, notifications and verify need view_attribution_reports. A key whose account's role lacked them could do all of it before; the registry and encoding reads, the integrity status, the store-link builder and the install token stay as open as the setup page
  • Fixed: Erasing an LTV customer now removes them from the identity graph too — the signal their customer id became and every observation of it, in the same transaction — so an erased customer's id no longer links anyone's clicks
  • Fixed: The upgrade page refuses MariaDB before 10.6 and MySQL before 8.0, as the installer does; it accepted MariaDB 10.0.12 and MySQL 5.6, where the upgrade would have stopped partway on the JSON columns
  • Changed: Recording a conversion or following a redirect writes no multi-touch attribution table: the report rollup's dirty marks, which the pixel's cost update and an old click's offer redirect write in their own transactions, are conversion tables now (the same two tables, created by the 1.9.75 step instead of the 1.9.56 one), so a missing or broken attribution table cannot fail either
  • Changed: The multi-touch attribution breakdowns (GET /attribution/reports/breakdown, the Attribution page's Report view, and exports) read a report rollup the attribution worker keeps — the credits, cost, assists and totals of every dimension and model, summed per hour and per UTC day — instead of joining every credit and click in the range. At 1M conversions a 30-day breakdown takes 0.1 to 1.25 seconds instead of 30 to 70. The answer is the full computation's, byte for byte: the part-hours at a range's edges, hours not summed yet (an hour is summed two hours after it ends), hours a change has made dirty, and — for the day grouping — an hour that is not one local date in the database's time zone are computed exactly in the same statement, and a test compares both on randomized data, time zones on and off the hour, and every change a conversion or click can go through. The rollup is summed by the worker after the upgrade (about 20 minutes of worker time per million conversions) and again after a campaign's model override or the account default changes; the reports compute in full meanwhile. It takes storage: at 1M conversions about 1.5 GB beside the 2.3 GB it sums
  • Changed: The multi-touch attribution journey metrics (GET /attribution/reports/journeys and the Attribution page's journey panel) read their counts — journey lengths, truncated and unidentified journeys, time to convert, and the one-touch share by browser — from the same report rollup as the breakdowns, instead of joining every journey and click in the range: at 1M conversions a 30-day read takes 0.02 seconds instead of 12.6. The answer is the full computation's, byte for byte, under the same rules as the breakdowns (edges, unsummed and dirty hours computed exactly in the same statement), and a rollup summed before this change is marked and summed again by the worker, the counts computing in full meanwhile
  • Fixed: The journey metrics no longer fail with a database error (HTTP 500) when a conversion in the range is dated before its journey's first touch; such a conversion counts as converting in under an hour
  • Fixed: A cron job run against a database that needs an upgrade now says so and exits 1 — "the database needs an upgrade: its schema is version X and this code is version Y" on stderr, and nothing run — where it used to exit 0 in silence, because connect.php answered with the web's redirect; the attribution worker looked like an idle one. The same holds for a missing 202-config.php, a missing vendor/ and a database that cannot be reached; on the web the redirects and pages are unchanged. Every job in 202-cronjobs/ now loads its bootstrap by its own path (nine loaded it relative to the working directory, so a crontab that did not cd into 202-cronjobs/ failed far from the cause), the minutely cron no longer leaves its lock behind when it cannot start, and sync-worker.php, which bootstraps the API, checks the schema version itself
  • Fixed: A database upgraded from 1.9.55 now has exactly the schema a fresh 1.9.76 install creates. Measured from the real 1.9.55 release, the upgrade left four differences behind: user_data_feedback was never created (the installer gained it during the 1.9.56 line and no upgrade step did), 202_users.user_pass stayed char(32) — too short for the password hashes the app writes, so a password set by a reset or through the API or CLI before the account's first login did not fit — 202_user_role kept a redundant user_id index, and the per-campaign attribution_model_id column landed in a different position. tests/live/upgrade-equals-install.sh installs 1.9.55 from its own commit, upgrades it through the upgrade page, and compares every table and every seeded row with a fresh install; it runs in CI
  • Fixed: The attribution worker processes conversions more than forty times faster on a large ledger. Every conversion's counted amount looked up the reversals naming it with no index, reading the whole 202_conversion_logs each time — at 1M conversions about 330 conversions a minute, a third of the 1,000 target; 202_conversion_logs gains KEY reverses_conv_id (install and upgrade), and the same backlog now clears at about 15,000 a minute with identical journeys and credits. The journey drill-down and recent-conversion reads use the same lookup
  • Changed: CI runs every integration suite — 38 database suites, each against a fresh database, where it used to run 9 named by hand (the Goals suites were among those that ran nowhere), and a suite that skips or exits before reporting now fails instead of passing; tests/Redirect/DlIntegrationTest runs against the Agent Evals instance. The DataEngine report suite, which exited 0 without running a test when there was no 202-config.php, now installs its schema and runs
  • Documentation: docs/openapi.yaml describes every operation the v3 router serves — the LTV, sync, forecast-event, bulk-upsert, /capabilities, /versions, /system/metrics, /reports/weekpart and rotator-rule-update operations were missing (72 in all) — and a structural test keeps the router and the document equal. The documentation index lists every page, and a test fails on a dangling link
  • New: Agent-eval cases for multi-touch attribution: first- and last-touch crediting different campaigns for one visitor's journey, creating a time-decay model in the right units, and refusing to delete the default model
  • Changed: Every page renders on the one Bootstrap 5.3 shell; the classic shell is gone with Bootstrap 3, Flat UI Pro, Font Awesome 4, jQuery 1.11, jQuery UI, Select2, tokenfield, typeahead, the tablesorter plugins, List.js and the old first-party stylesheets and scripts (custom.css, p202-ui.css, design-system.css, custom.php). Pages load about half the CSS and JavaScript they did on the classic shell (51-59% less after gzip on the pages measured). template_top() no longer takes a ui option and refuses one, or any option it does not define, with a sentence saying to delete it
  • Fixed: The "new version available" notice under the header is back. It was drawn only by the classic shell, so once every page had moved it was shown nowhere and the footer always read "up to date"; it is now a dismissible notice (hidden for an hour when closed) whose text from the release feed is shown as text
  • Changed: The off-by-default JSON report transport no longer returns dependentFilters (Bootstrap 3 dropdowns for the classic report calendar); a request that sends includeDependentFilters is refused by name with a 422
  • New: Setup › Mobile Apps registers Android apps too, from the Play link (a bare App Store id or package name works), with the name and icon read from the store (icons only from Apple's and Google's image hosts, at most 40 KB, stored with the registration). Each app's page has its settings (Android: attribution window, trust_client_revenue), a store link with a builder that points a campaign at it and — for Android — links the campaign to the app in one confirmed click, and its goals in funnel order with the campaign goal form (a window from the install, After as the funnel step; a goal the form cannot show whole is left to p202 goal update). iOS values name a goal from a menu and warn, where they are edited, until when a postback is ambiguous_encoding; Android pages add the intake, recent installs and Play Integrity (service account, then mode and Cloud project number; a key is never shown back)
  • New: Analyze › Mobile Apps reports both platforms. GET /apps/report takes platform=ios (the default, as before Android), android or all: Android groups by day, app, platform, campaign, match state, integrity state or goal (the funnel), counting installs by install with goals reached and payable revenue; with both, totals are {ios, android, combined}. The page adds a platform picker (the app decides the default), a Funnel tab, and a Postbacks sent tab (GET /apps/notifications: the traffic-source postbacks app goals queued, one row per pixel URL, counted by status); the iOS report shows ambiguous_encoding. p202 app report --platform, p202 app link, p202 app notifications, and app:report / app:link / app:notifications in bin/p202
  • New: Correction URLs. Setup › Traffic Sources gives each server postback pixel an optional correction URL, one per pixel URL in the same order; a correction or retraction for an outcome a network already heard is queued there with [[p202_goal_value]], [[p202_previous_value]], [[p202_conv_id]], [[p202_original_conv_id]] and [[p202_notification]], and stays suppressed, with the reason, where there is none
  • New: GET /apps/{id}/store-link?campaign_id= — the store link a campaign should send its clicks to, whether the campaign is ready, and the update that would make it so
  • New: Play Integrity for Android installs, opt-in per registration (integrity_mode off / observe / require). The SDK requests a standard token bound to the install (requestHash = SHA-256 of the canonical install body); 202-cronjobs/app-installs.php decodes it through Google's decodeIntegrityToken with the operator's service account (set at PUT /apps/{id}/integrity-credential, stored AES-256-GCM encrypted, never shown), off the request path, retrying for 24 hours. A verdict is valid only for this app's package, this install's hash, a fresh token, PLAY_RECOGNIZED, device integrity and a licensing verdict that is not UNLICENSED. observe records verdicts only; require holds an install that would be attributed as pending_integrity and attributes, pays and notifies only on a passing verdict (integrity_failed / integrity_unverified otherwise). GET /apps/{id}/integrity, installs filterable by integrity_state; p202 app integrity status|credential set|credential clear, app update --integrity-mode, and app:integrity:* in bin/p202
  • New: Android install tracking. A campaign's Google Play store link carries referrer=p202%3D[[p202_install_token]], the click id signed with a key minted at install or upgrade (a bare click id could be credited by anyone); the Android SDK reports the install to the public POST /api/v3/apps/installs (selected by the app token) and its events to POST /apps/installs/{install_uuid}/events. Each install is classified — attributed, organic, third_party, unavailable, pending_click, bad_token, foreign_click, implausible, outside_window or duplicate_click — with a reason, and an attributed one is a conversion on its click (one per click, pixel_type 4, at Google's install time) through the built-in install goal, which a campaign pays at its default payout unless it lists goals without it. Install, conversion, MTA outbox and traffic-source notification commit in one transaction; replays answer as duplicates, a reused install id with other content is 409. Registrations gain attribution_window_days (default 7) and trust_client_revenue; campaigns gain app_registration_id. 202-cronjobs/app-installs.php (every minute) settles installs whose click was not yet written and sends the queued server postbacks with [[p202_goal]] and [[p202_goal_value]], retrying with backoff; a traffic source hears about an outcome once. Operators read installs at GET /apps/{id}/installs and a click's token at GET /apps/{id}/install-token; p202 app install list|get|token|simulate (and app:install:list|get|token in bin/p202)
  • Changed: App measurement has its own names, apart from multi-touch attribution — the tables are 202_app_registrations, 202_app_postbacks and 202_app_skan_encodings; the API is /api/v3/apps (registry), /apps/skan-encodings, /apps/postbacks, /apps/report, /apps/verify and the public /apps/schema, in a new apps scope area, so a key scoped to one of app measurement and attribution cannot touch the other; the Go CLI is p202 app … (app encoding … for the SKAN encodings); /capabilities advertises features.app_platforms and features.app_postbacks. The entries below that name /attribution routes, 202_attribution_* tables, p202 attribution app|cv|postbacks|report|verify|schema, schema tokens or accept_development_postbacks describe the same features under these names
  • New: One app registry for both platforms — an app is (platform, app_key): the App Store id for iOS, the package name (case-sensitive) for Android. Register from a store link (store_link / --store-link: an App Store or Google Play link, a market:// link, an App Store id or a package name), which the server reads with one parser; every other app table links to a registration by registration_id. The app a registration names is fixed; SKAN encodings must name one of your own iOS registrations or be account-wide
  • Changed: The schema token is the app token (app_token, header X-P202-App-Token, rotated at POST /apps/{id}/app-token/rotate) and is documented as an identifier rather than a secret — it ships in every copy of the app. accept_development_postbacks is accept_test_signals, and a postback's trust is one column, trusted (1 trusted, 0 refuted, NULL unvouched), reported as trusted_count / refuted_count / unvouched_count / test_count; the retention cron is 202-cronjobs/app-retention.php, configured with P202_APP_RETENTION_DAYS_POSTBACKS_UNCLAIMED / _REFUTED / _UNVOUCHED
  • Fixed: Deleting a user — from Account › User Management or DELETE /api/v3/users/{id} — now purges, in the same transaction as the delete, their app registrations and SKAN encodings (freeing each app for someone else to register; a deleted user's registration used to hold its slot forever), their identity-graph rows and their MTA state, and releases their postbacks to unclaimed; it also revokes their REST API keys, which used to keep working after the delete (sessions already refused a deleted user). The API delete used to purge nothing
  • Fixed: The upgrade page (202-config/upgrade.php) now requires the session token on its POST, the same check the installer and the login form make. It runs before there is a login, so the token was the only thing standing between a cross-site form and a schema upgrade run at someone else's moment; a request without it is refused with a sentence and 202_version is left alone. tests/live/upgrade-csrf.sh proves it against a running instance
  • New: Analyze › Mobile Apps — the SKAdNetwork and AdAttributionKit report, as three tabs over one filter row: Report (six tiles, and the totals grouped by day, app, ad network, source, country, protocol, version or conversion type, with a decoded-events panel and Download to CSV), Postbacks (the rows behind the totals, fifty to a page, each with its transaction id and conversion window), and Verify (paste a postback, get a signature verdict and the exact bytes Apple signs, nothing stored). Installs, losses and revenue count signature-verified postbacks only and the page says so with the count for each signature state; every filter lives in the URL, so a report is a link you can send, and a filter the page cannot use is dropped with a sentence rather than replacing the report with an error. Dates here are whole UTC days, which the toolbar says, because that is how the report groups them
  • New: The range picker the reports will share — the same preset windows as the click calendar (Today, Yesterday, Last 7/14/30/90 Days, This Month, Last Month) plus Custom Date, which is what makes the two date fields count. They stay editable at every range, because a field nobody can type into is the wrong way to say "this is not the control you want"; what changes is whether the browser submits them, so choosing a preset can never be a silent no-op and typing a date selects Custom Date for you. p202-ui.js does both, so the enhancement saves a round trip rather than being required for the page to work
  • New: Setup › Mobile Apps — register the apps you advertise from one field (paste the App Store link and the id, platform and name are read from it), see both receivers check themselves, and open an app for its conversion-value rules, its schema token, the Info.plist keys and SDK snippet filled in with this install's URL, and the postbacks it has received. The first page built on the new UI standard
  • New: One UI standard for the whole site, introduced without a big bang — a second page shell (template_top($title, ['ui' => 'v2'])) loads Bootstrap 5.3 with a Prosper202 theme expressed as Bootstrap's own CSS variables (light and dark, no Sass build) and a first-party component layer (202-css/p202-components.css: page header, tabs, panel, tiles, pills, report table, list, empty state, code box, status strip); pages migrate one family at a time while untouched pages keep today's stack unchanged
  • New: The chrome every page shares — navbar, Prosper202 CS section tabs, sub-menu strip, content frame and footer — is rebuilt as framework-neutral markup styled by 202-css/p202-chrome.css, so it looks the same on migrated and unmigrated pages; the account menu is a <details> element that works without a framework script, and the v2 shell adds a light/dark/system theme switch to it
  • New: 202-account/ui-kit.php (admins) renders every component of the standard in every state, as the reference for reviews and screenshots
  • New: The standard's UX rules — the app decides what it can and says so: forms show the common case with everything else under a closed "Advanced" disclosure that remembers its state, every default is pre-selected and explained, a value the app can find is never asked for, and pages nudge instead of adding settings
  • New: AdAttributionKit measurement endpoint — point an iOS app's AttributionCopyEndpoint (iOS 17.4+; add EligibleForAdAttributionKitReengagementPostbackCopies for re-engagement copies, iOS 18+) at your Prosper202 domain and it receives Apple's AdAttributionKit postback copies at /.well-known/appattribution/report-attribution/, beside the SKAdNetwork receiver; the JWS is decoded and its ES256 signature verified against the key its kid names (Apple's production key, or one of the two development keys), the payload validated under Apple's field names, and the row stored with the framework's own conversion-type (download, redownload, re-engagement) and ad-interaction-type (view, click) — SKAdNetwork rows carry the same two dimensions derived from their redownload and fidelity-type flags, so both frameworks share one table, one report and one set of conversion-value rules
  • New: Development-signed AdAttributionKit postbacks are untrusted by default — any phone in Developer Mode can mint one naming any App Store id — and stored flagged development; an app registration's accept_development_postbacks (API field, p202 attribution app create/update --accept-development-postbacks 1) trusts them for that app while integration-testing and re-flags the rows already stored when toggled either way
  • New: The postback list and report gain a protocol dimension — protocol (skadnetwork/adattributionkit, shorthands skan/aak), conversion_type and ad_interaction_type filters, signature=development, group_by=protocol|conversion-type, a reengagements metric beside installs and redownloads, and a signature_development_count column; /capabilities lists the protocols the server receives in features.attribution_postbacks, and POST /attribution/verify verifies an AdAttributionKit body (detected by its jws-string) returning the verdict, the key id and the decoded header and payload
  • New: The report counts unique postbacks — protocol, ad network, postback id and conversion window — and decodes each unique postback through its first-received copy, so resending a genuine signed postback with different (unsigned) conversion values counts once instead of minting installs and revenue; a second integration suite against a real database covers the SQL
  • New: P202Attribution iOS helper reports every conversion update to AdAttributionKit as well as SKAdNetwork (iOS 17.4+), and logEvent(_:conversionTypes:) scopes an update to AdAttributionKit's install and/or re-engagement postback (iOS 18+) — a re-engagement-scoped update is never sent to SKAdNetwork and keeps its own last fine value
  • New: SKAdNetwork (SKAN) measurement endpoint — point an iOS app's NSAdvertisingAttributionReportEndpoint at your Prosper202 domain and it receives Apple's install-validation postbacks at /.well-known/skadnetwork/report-attribution/ (a real directory, so the shipped Apache/nginx configs serve it with no rewrite rules), making Prosper202 the server side of a mobile measurement partner
  • New: SKAN signature verification — every postback is checked against Apple's published P-256 key using the exact per-version signed-message composition for SKAN 2.1 through 4.0; forged or unverifiable postbacks are stored flagged (signature_valid) rather than dropped, and because the receiver is public the report counts only signature-verified postbacks in its headline metrics by default (meta.trusted says so; an explicit signature filter widens it, and per-group signature_valid/invalid/unverified counts always show what was excluded)
  • New: SKAN app registry and conversion-value decoding — register advertised App Store apps (registering claims postbacks that arrived before it; an App Store id can only be registered once so ownership is never ambiguous), and mirror the in-app conversion-value schema as decoding rules (fine values 0-63 and coarse low/medium/high, per app with account-wide defaults) so reports translate values into named events and revenue
  • New: /attribution API v3 routes under the existing attribution scope area — read-only postbacks with filters (including signature state), an aggregate report grouped by day, app, ad network, source, country, or version with decoded revenue and per-event counts, and POST /attribution/verify to check a captured postback's signature without storing anything; staged writes and dry-run delete previews work like the rest of the operator surface, conversion-value creates take an Idempotency-Key, and app registration deliberately does not record replayable idempotency responses (its response carries the schema token, which must never persist in server state — retries are safe anyway, a duplicate registration answers 409)
  • New: p202 attribution CLI commands — postbacks list/get, the decoded report, app and conversion-value CRUD with the standard safety flags, and p202 attribution verify reading a postback from a file or stdin; agent-eval cases cover registration claiming postbacks and refusing to report unverified postbacks as verified installs
  • New: The public SKAN receiver dedupes true device retries — the transaction id namespaced by ad network and postback leg plus the exact request body, so a replay answers 200 with duplicate: true while a crafted variant naming the same transaction stores as its own flagged row instead of occupying the genuine postback's slot — rate-limits per source IP (keyed on the TCP peer, never client-supplied headers), answers non-200 on storage failures so devices retry later instead of the postback being lost, and prunes aged rows nobody will act on (unclaimed after 30 days, forged or unverifiable after 90; P202_ATTRIBUTION_RETENTION_DAYS_UNCLAIMED/_INVALID/_UNVERIFIABLE override, 0 disables, and a value that is not a whole number of days prunes nothing rather than falling back to the default)
  • New: Remote-configured SKAN conversion values — registering an app now mints a schema token, and GET /api/v3/attribution/schema serves that app's conversion-value mapping to its iOS build at runtime (ETag/304 conditional fetches, per-IP rate limiting, event names and values only — never revenue), so changing what conversion values mean is a rule edit in Prosper202 instead of an App Store resubmission; the token travels only in the X-P202-Schema-Token header (never a query string, which access logs would capture) and is redacted from staged-change apply results and delete previews
  • New: P202Attribution iOS helper (sdk/ios-attribution) — a dependency-free Swift package that fetches and caches the schema (offline-safe, token-keyed cache) and maps logEvent("purchase") to the right SKAdNetwork.updatePostbackConversionValue call; unmapped events are deliberate no-ops, and coarse-only updates re-send the last fine value rather than downgrading it
  • New: Schema token rotation — POST /attribution/apps/{id}/schema-token/rotate (CLI: p202 attribution app rotate-token) mints a replacement and invalidates the old token immediately, the remedy for a token leaked out of a shipped binary
  • New: p202 attribution schema <id> shows the conversion-value document exactly as devices fetch it, by performing the same header-tokenized request the iOS helper makes; p202 attribution cv update grows --clear-fine-value/--clear-coarse-value so a rule can switch between fine and coarse in one command, and attribution app/cv lists take --all
  • Changed: The Setup button grid and the Overview/Analyze/Update report strip are part of that shared chrome now — framework-neutral markup with inline SVG icons in place of Bootstrap 3 grid classes and the Glyphicons font, the current item marked with aria-current for screen readers, and the setup pages' panel styles moved out of an inline <style> block into 202-css/custom.css, scoped by the new p202-section-* / p202-sub-* body classes
  • Changed: The header shows one logo, the Prosper202 banner placement it has always used, instead of that placement plus a static copy of the logo beside it
  • Changed: Every third-party front-end file is pinned and served from the install — Bootstrap 3.3.4, jQuery 1.11.2, jQuery UI, Select2 and the tablesorter plugins that loaded unversioned from a CloudFront bucket are vendored with their versions and SHA-384 hashes in 202-config/assets.php, and Highcharts loads a pinned version instead of the CDN's rolling build; tests/Api/V3/AssetManifestTest.php fails the build when a file drifts from the manifest. The third-party files that were always in the repository but loaded by bare path — Bootstrap 3, Flat UI Pro, Font Awesome, Select2, tokenfield, typeahead, jQuery Validation, tablesort, List.js — carry their version in their filename and their hash in the manifest too, and the standalone pages (login, install, password reset) and the mobile mini-stats page load those pinned copies instead of a CDN
  • Changed: The page shell adds the viewport meta tag, drops the CloudFront background image for a flat page ground, and no longer references a dropzone.js that did not exist or the empty iio-rum.min.js placeholder
  • Changed: Nothing in the tree loads a script or stylesheet from an external host any more, except Highcharts (pinned) and two hosted-service loaders; tests/Api/V3/ShellIsolationTest.php sweeps every PHP, JavaScript and HTML file and fails the build on a new one
  • Fixed: The sub-menu strip scrolled its current entry into view only on narrow screens, so on a desktop a long strip — Analyze is fifteen entries — left the current page's own entry clipped off the right edge. Whether a strip fits is a question about the strip, not the window
  • Fixed: Two paragraphs on Setup › Mobile Apps wore .p202-help, which is the help-icon component rather than a name for muted text, so each rendered as a shrink-wrapped inline-flex box at 0.9em with a help cursor instead of a paragraph. A pill used as a link no longer carries the browser's underline on top of its own shape
  • Fixed: The account menu closed on an outside click and the theme switch responded only if the browser happened to parse the header before the chrome script ran; both now bind once the page is parsed
  • Fixed: The in-app documentation viewer renders Markdown properly — tables as tables, numbered and bulleted lists as lists, prose in paragraphs, and code escaped rather than emitted as markup
  • Fixed: --quiet id output now prints the row's own primary key for SKAN apps, conversion-value rules, postbacks, and forecast events instead of falling back to the owning user_id
  • Fixed: Applying a staged change no longer returns the proposer's credentials to the applier. A staged PUT /users/{id}/preferences answered with the whole preferences row, so an admin applying someone else's proposal received their API keys, secret keys, signatures and Slack webhook in the clear; apply results and staged previews are now redacted by the same rule the rest of the surface uses, and a structural test fails if a new credential column is added to the preferences table without being covered
  • Fixed: The personalization endpoints' rate limit keyed its buckets on client-supplied forwarding headers, so a flood with a random header per request both evaded the limit and created one state file per request; both endpoints now key on the connecting peer and their buckets are pruned, matching the attribution receivers
  • Fixed: Rate-limit buckets could be shared by different callers. The bucket filename dropped the characters that told two IPv6 addresses apart, so 2001:db8::1 and 2001:db8:1:: counted against one ceiling and a flood from one peer answered 429 to the other
  • Fixed: The upgrade step now reconciles an existing table against its definition instead of only creating missing tables, so a schema that predates a column or index converges in place rather than silently staying stale, and pre-existing rows are backfilled for columns added as NOT NULL
  • Documentation: documentation/features/ui-standard.md describes the standard, the two shells, the framework-neutral chrome, the asset manifest and the migration order

What's Changed

  • subid missing in pixel url by @nevstas in #4
  • Update README.md for version 1.9.56, adding PHP 8 support by @tracking202 in #6
  • Fix mysql error logging by @tracking202 in #7
  • Fix comment typos by @tracking202 in #8
  • Add PR workflow for PHP lint by @tracking202 in #12
  • Fix newline at end of index by @tracking202 in #11
  • Add PHP lint check by @tracking202 in #10
  • Update release notes in README by @tracking202 in #9
  • [Core] Update PHP version check for PHP 8 by @tracking202 in #13
  • [CI] Fix PHP lint action by @tracking202 in #14
  • [Fix] Return 404 status by @tracking202 in #15
  • [Maintenance] Remove deprecated PHP features by @tracking202 in #16
  • [Config] Update PHP version checks by @tracking202 in #17
  • [Config] Centralize PHP version check by @tracking202 in #18
  • [Security] Sanitize inputs and secure sessions by @tracking202 in #19
  • [Redirect] Improve dl.php redirect performance by @tracking202 in #20
  • [Redirect] Improve input handling by @tracking202 in #22
  • [Setup] Fix undefined array key warnings in text ads page by @tracking202 in #23
  • [Setup] Remove undefined variable from template calls by @tracking202 in #25
  • [Update] Prevent warnings in subid deletion by @tracking202 in #28
  • [Update] Fix CPC warnings and input length by @tracking202 in #27
  • [Setup] Avoid undefined variable notices by @tracking202 in #26
  • [Update] Handle missing case parameter by @tracking202 in #29
  • Add Claude Code GitHub Workflow by @tracking202 in #30
  • New changes by @tracking202 in #31
  • New changes by @tracking202 in #32
  • [Tests] Add dl.php coverage by @tracking202 in #21
  • Update php-unit.yml by @tracking202 in #35
  • Fix undefined mysql array keys by @tracking202 in #34
  • Fix undefined array key warnings and TypeError in aff_campaigns.php by @tracking202 in #36
  • Fix PHP 8+ compatibility issues throughout Prosper202 by @tracking202 in #33
  • Remove Intercom integration by @tracking202 in #37
  • Fix PHP 8 TypeError in landing pages by @tracking202 in #38
  • Fix undefined array key warnings in ajax/landing_pages.php by @tracking202 in #39
  • Fix multiple number_format and round TypeErrors in DataEngine class by @tracking202 in #42
  • Secure health endpoint with authentication requirement by @tracking202 in #43
  • Fix PHP 8+ compatibility errors across tracking system by @tracking202 in #41
  • Fix undefined array key warnings and TypeError in generate_tracking_link.php by @tracking202 in #40
  • Improve git hygiene with .gitignore updates by @tracking202 in #44
  • Add comprehensive documentation and fix grammar/spelling errors by @tracking202 in #45
  • Fix PHP 8+ compatibility and improve installation process by @tracking202 in #47
  • Fix ini_set() PHP 8+ compatibility issues by @tracking202 in #48
  • Add advanced attribution engine infrastructure by @tracking202 in #73
  • [Tests] Add comprehensive test coverage for core components by @tracking202 in #74
  • Multi-touch attribution: Core implementation and UI improvements by @tracking202 in #79
  • [CI] Normalize workflow failure handling by @tracking202 in #54
  • Modernize functions-install.php with modular architecture by @tracking202 in #80
  • PHP 8 compatibility, UX redesign, and infrastructure fixes by @tracking202 in #81
  • Remove PHP opening tag and strict types declaration by @tracking202 in #82
  • Add p202 CLI and REST API v3 for full remote Prosper202 management by @tracking202 in #83
  • Add UI-friendly CLI aliases and improve command descriptions by @tracking202 in #84
  • Modernize codebase with Rector for PHP 8.0-8.3 compatibility by @tracking202 in #86
  • Implement incremental spy mode with read-only DB connection by @tracking202 in #88
  • Update MySQL db code by @tracking202 in #90
  • MySQL modernization: repository layer, type safety, and performance by @tracking202 in #91
  • [202-config] Prevent post-install installer reuse privilege escalation by @tracking202 in #95
  • [API] Prevent attribution user_id override from JSON body by @tracking202 in #94
  • [202-config] Secure auto-update download and extraction handling by @tracking202 in #93
  • [202-account] Harden auto-upgrade package handling by @tracking202 in #92
  • Update license from GPL-2.0 to BSL 1.1 by @tracking202 in #89
  • Align README with homepage positioning and feature set by @tracking202 in #96
  • Update README to use nginx instead of Apache by @tracking202 in #97
  • Clarify web server requirements: support both Nginx and Apache by @tracking202 in #98
  • Add mission-critical functions inventory by @tracking202 in #104
  • Add Claude Code GitHub Workflow by @tracking202 in #105
  • Optimize landing page tracking script: remove dead code, fix XSS, modernize JS by @tracking202 in #103
  • Modernize core PHP (8.3–8.5), tighten input handling, and fix correctness bugs by @tracking202 in #106
  • Pre-release audit: fix money-path crashes, security (IDOR/SQLi/CSRF/priv-esc), API strict-mode, and report accuracy by @tracking202 in #107
  • Update documentation to reflect current v3 API, CLI, and features by @tracking202 in #102
  • Sync v3 API docs with pre-release audit changes (PR #107) by @tracking202 in #108
  • Add forecast command to Go CLI (merge-ready continuation of #101) by @tracking202 in #109
  • Add forecast command to Go CLI with 4 statistical methods by @tracking202 in #101
  • Add interactive shell mode to Go CLI for session persistence and batch execution by @tracking202 in #100
  • Add interactive shell mode to Go CLI for session persistence and batch execution by @tracking202 in #110
  • Raise minimum PHP version gate to 8.3 to match the actual requirement by @tracking202 in #111
  • Apache/Nginx click-server docs, working Docker stack, and installer hardening by @tracking202 in #112
  • Refactor the data engine into testable components with a compatible facade by @tracking202 in #113
  • docs: sync README and docs with codebase (attribution count, forecast events) by @tracking202 in #114
  • Add release packaging that bundles vendor/ and CLI binaries by @tracking202 in #115
  • Harden login and authentication flow by @tracking202 in #117
  • Harden installer account creation: transactions + prepared statements by @tracking202 in #116
  • Make conversion recording idempotent and atomic across all ingestion paths by @tracking202 in #118
  • Add Intercom-style messaging system; remove dashboard-content sync by @tracking202 in #119
  • Pre-release QA fixes: V3 API 500s, fresh-install tracking crashes, install/schema hardening by @tracking202 in #120
  • Fix V3 API 500s on ppc-network/ppc-account/tracker create under strict mode by @tracking202 in #124
  • Fix empty upgrade changelog and modernize HTTPS upgrade prompt by @tracking202 in #121
  • Fix V3 API tracking link: direct_url should use dl.php, not go.php by @tracking202 in #123
  • Fix fatal error in redirect hot path (go.php missing autoloader) by @tracking202 in #122
  • Create 202_forecast_events on upgrade (fix V3 forecast-events 500) by @tracking202 in #126
  • Fix setup-tab layout and alignment issues by @tracking202 in #128
  • Make the Get Started checklist dynamic per onboarding progress by @tracking202 in #127
  • Docker dev tooling and build/ contributor docs by @tracking202 in #129
  • Fix check-code-patterns Stop hook aborting under set -e by @tracking202 in #132
  • Fix tracker tracking-URL to match redirect handler by type by @tracking202 in #130
  • Align report breakdown and rotator rule-update flags with siblings by @tracking202 in #131
  • Fix rtr.php dropping clicks for a rotator with no rules by @tracking202 in #133
  • Fix gpb.php postback crashing on NULL click fields (S2S conversions broken) by @tracking202 in #135
  • CLI friction & missing-capability improvements (from audit) by @tracking202 in #134
  • Customer LTV tracking: identity, revenue ledger, subscriptions, companies, personalization, and full UI/API/CLI by @tracking202 in #136
  • Bandit bridge: Prosper202 edge integration for the Landing Page Optimizer by @tracking202 in #137
  • Bandit bridge v2: on-change dimension sync (G10); cmp-on-LP investigated no-fix (G11) by @tracking202 in #138
  • Tests: unit coverage for bridge idempotency-key shapes by @tracking202 in #139
  • Bandit bridge v2 follow-up: CAS-persist markDirty and the ctx_key backfill (Codex round 5) by @tracking202 in #140
  • Rebrand: bandit → lpo (whole-source scrub, public repo) by @tracking202 in #141
  • JSON report transport for the flat Analyze reports (off by default), and fix pagination counts by @tracking202 in #142
  • Add Coolify deployment support by @tracking202 in #144
  • Add rolling-origin conformal prediction bounds and ensemble forecasting by @tracking202 in #146
  • Agent-safe operator surface: scoped keys, staged writes, evals, and the static analysis to hold them by @tracking202 in #147
  • Agent workflow: verification ladder, invariant sweep, adversarial review; fix the pattern hook's blind spots by @tracking202 in #150
  • UI standard wave 0: two page shells, framework-neutral chrome, pinned assets by @tracking202 in #152
  • SKAdNetwork and AdAttributionKit measurement: postback receivers, app registry, conversion-value decoding, runtime schema, P202Attribution iOS helper (1.9.76) by @tracking202 in #149
  • UI standard wave 1: Setup › Mobile Apps and Analyze › Mobile Apps on the v2 shell by @tracking202 in #153
  • Upgrade ladder: remove the unreachable 1.9.76 block, and pin the shape that made it unreachable by @tracking202 in #154
  • Require the session token on the upgrade page's POST, and close the three ladder checks left open on #154 by @tracking202 in #155
  • feat: keep the header logo iframe in step with the dark theme by @tracking202 in #184
  • Measurement rewrite (1.9.76): conversion ledger, goals, iOS + Android apps, MTA rewrite, whole app on v2 UI by @tracking202 in #157
  • Android abuse limits, upgrade backup warning, and the Android SDK built, linted and tested (PR 14 + 15) by @tracking202 in #185
  • Release zip: ship only what the app needs, from a locked vendor/, verified before zipping; pin every action by @tracking202 in #156
  • feat(go-cli): find campaigns by offer URL and rewrite offer URLs in bulk by @tracking202 in #188
  • feat(go-cli): campaign check-urls (no clicks sent) and campaign list --with-stats by @tracking202 in #189
  • feat(go-cli): discovery (search, commands, valid values), agent JSON output, cert/cron health, --split-at, conversion import by @tracking202 in #190
  • Bump the github-actions group with 9 updates by @dependabot[bot] in #187
  • 1.9.76 release notes and README; fix 1-click upgrade notes and warning, cron tier gates, two CLI defects by @tracking202 in #191
  • Harden API v3 and CLI: fail-closed auth, sync data-loss guards, DRY shared helpers by @tracking202 in #148
  • Correct the update_needed() reason in UpgradeBackupWarningTest and pin it by @tracking202 in #192
  • Attribution-aware report losers and winners; attribution breakdown --offset by @tracking202 in #193
  • Attribution breakdown API: offset to read past the first 1,000 rows by @tracking202 in #194
  • verify.sh: gofmt before the toolchain switch; the empty-HOME run keeps Go's caches by @tracking202 in #151
  • Attribution breakdown: cohort=click, so losers/winners check the classic rows' own clicks by @tracking202 in #196
  • Bump the github-actions group with 2 updates by @dependabot[bot] in #197
  • fix: six server bugs (click_bot, stale fallback URL, breakdown tie-breaker, timeseries truncation, conversions duplicate/409, CLI NaN/encoding) by @tracking202 in #195
  • report breakdown filters: a malformed response is a server error, not a flag error by @tracking202 in #199
  • report losers/winners: --payout, so sources that sell at a loss get checked by @tracking202 in #198

New Contributors

Full Changelog: https://github.com/tracking202/prosper202/commits/v1.9.76