Skip to content

v1.9.79

Latest

Choose a tag to compare

@github-actions github-actions released this 01 Nov 03:33
· 7 commits to master since this release
782856a

Update now: this release includes security fixes

1.9.79 fixes security problems in the account pages, sign-in sessions, integration settings, traffic sources, syncs and the container image. Update every Prosper202 install to 1.9.79 as soon as you can. After the upgrade, a user you switch off or remove is signed out everywhere, and changing a password ends every other sign-in and every "Keep me signed in" cookie, so change any password you think may be known to someone else.

If you run Prosper202 in Docker, rebuild the image too. On Coolify, redeploy; with docker compose, run docker compose build --pull, then docker compose up -d.

⚠️ 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. 1.9.79 records more about each click and conversion (why a click was filtered, how a conversion found its click, more ad-platform click ids), adds click-filter, offline-conversion and GeoIP settings, and gives redirector rules an order of their own. Clicks re-clicked through a redirector link are brought to one visit each by the cron job after the upgrade, not by the upgrade page; p202 system dataengine shows how far it has got.

Take a full mysqldump (or your host's snapshot) of the Prosper202 database after the site stops taking traffic, then replace the files with the 1.9.79 release zip and open 202-config/upgrade.php.

Check after upgrading:

  • Customer LTV now needs a role permission. Viewing it needs Access to campaign data; changing customers, companies and products needs Access to the Setup section; LTV Settings needs Access to API integrations. Give these to any user who should keep using it.
  • Match conversions by IP address is now a per-account setting (Personal Settings › Advanced).
  • Daily email report is gone from Personal Settings: Tracking202 has switched that service off.

Download

Download prosper202-1.9.79.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+, as for 1.9.78. Building the Go CLI from source now needs Go 1.27.3; the release binaries are built with it.

What's new and what changed

New

  • Click filtering you control (Personal Settings › Click filtering): switch each filter check on or off, choose the repeat-click window, and keep an always-count / always-filter address list. Every filtered click now says why.
  • Offline conversions for Google Ads and Microsoft Advertising (Setup › Offline Conversions).
  • GeoIP database panel in Account › Settings, with automatic updates from your own free MaxMind GeoLite account.
  • Clicks record Google Ads' iOS click ids (gbraid, wbraid) and TikTok's ttclid; Microsoft's msclkid and Meta's fbclid are recorded from direct links and landing pages.
  • Every conversion records how its click was matched.
  • AI assistants connected through MCP can manage traffic sources, text ads, redirectors and their rules, and the click filter.
  • Redirector rules can be reordered, and a whole rule set saved in one write.
  • Dead LTV webhooks can be revived.
  • Admins can find and return traffic sources that an older rename moved into another account.

Changed

  • A click re-clicked through a redirector link counts once in every report, under its latest visit, and its conversions are credited to that visit.
  • New accounts start with privacy set to "Enabled for European Traffic".
  • If Tracking202 cannot be reached, the licence check allows a 30-day grace period.
  • The LTV API answers money and rates as numbers.
  • The hosted MCP engine uses far less memory on large answers.

Fixed

  • Many fixes to ClickBank, JVZoo and PayKickstart notifications (ClickBank rebills, refunds and chargebacks are now recorded), LTV webhooks and subscriptions, legacy v1/v2 report API errors, CLI report figures (losers/winners, breakeven, campaign optimize), Get Links on HTTPS installs, redirector syncs, the cron job lock, and multi-touch attribution.
  • A missing or damaged GeoIP database no longer stops a click.
Full changelog (for developers)
  • New: An AI assistant connected through MCP (p202 mcp, or Tracking202's hosted MCP server) renames and deletes traffic sources, their accounts and categories: ppc_network_update, ppc_account_update and aff_network_update run p202 ppc-network, ppc-account and aff-network update (a rename, an account's default flag, an account moved to another of your sources), and ppc_network_delete, ppc_account_delete and aff_network_delete their delete (dry_run first). Until now it could only list and create them, so asked to rename "Google Ads - Main" to "Main" it could only make a second source. A delete removes that one record, as Setup › Traffic Sources and Setup › Categories do: a traffic source's accounts and a category's campaigns stay, and each tool's description says so. A hosted connection needs no new approval for them: the access it was approved with already covers traffic sources and categories
  • Changed: A redirector re-click (?lpr=) keeps any c1 to c4 value it does not bring; it rewrote all four
  • Changed: A redirector re-click (?lpr=) keeps the traffic source and cost of the click's first visit, and whether that visit was filtered or a bot, and takes its campaign, landing page and payout from the visit it makes now; a click that has converted keeps its lead and value on the new visit. A re-click's row took the redirector link's traffic source and cost and no lead, and the click filter's verdict on the re-click itself, which marks a second click from one address within a day as filtered. On a re-click the Landing Page Optimizer's context token carries the click as stored (its traffic source account, its keyword and its c1 to c4), where it carried the link's traffic source account and this visit's keyword and c1 to c4. Two requests on one click in the same second are one visit: the later rewrites the earlier's rows instead of adding a row the reports would count again. A re-click that deadlocks with another request (two re-clicks of older clicks at once can, on an install whose click table is partitioned) is tried again, up to four tries in all with a short pause between, instead of ending on the database error page; under eight concurrent re-clickers, about 1 in 500 still lost all four
  • Changed: Every report counts a click a redirector link re-clicked once, under its latest visit: that visit's campaign, landing page, rotator route and time, with the traffic source and cost the click keeps from its first visit. They counted it at each visit, each under its own campaign, traffic source and cost, and counted its lead and income at each visit that held them, so a re-clicked click added clicks and cost, and once it converted leads and income, on every visit. A click from Monday re-clicked on Friday is now counted on Friday, at Monday's cost, and no longer on Monday. This holds for the Overview (its Rotator Breakdown too), Analyze, the group report, Spy and Visitors (and the Visitors download), GET /reports/* and GET /rotators/{id}/stats (p202 report, p202 rotator stats), the attribution breakdown and its exports, the LTV acquisition breakdown, customer and company engagement and recommendations, the legacy GET /api/v1/reports/ and /api/v2/reports/, and the Android installs in GET /apps/report. GET /clicks still lists each visit. GET /system/retention's clicks_remaining for a deletion scheduled by click id counts each click once, where it counted each visit
  • Fixed: An attribution journey listed a click a redirector link re-clicked as one touch per visit; a journey built now holds it once, named by its latest visit, and its converting click is its last touch only. A journey built before keeps its touches until it is rebuilt. In the click cohort a click's credit and assists fall at the time of its latest visit, once
  • Changed: A conversion on a click a redirector link re-clicked is credited to the click's latest visit: recorded under that visit's campaign, at that visit's payout and under its campaign's payout mode, with that visit's time. It was recorded under whichever of the click's visits the database read first, usually the first, so a sale of the offer the re-click sent the visitor to was recorded on the first visit's campaign. This holds wherever a conversion is recorded (the pixels and postbacks, POST /conversions, Update Subids, revenue uploads, goals and web events, app installs), and GET /clicks/{id}/conversions (p202 click conversions) shows the latest visit's value. A click that converted and was re-clicked before 1.9.79, whose re-click row was stored as no lead, is still one conversion for a pixel or postback without a transaction id, and for Update Subids: they ask whether any visit is a lead. The per-campaign postback (pb.php, with or without event=) accepts a click any of whose visits was on the campaign it names, when that campaign is the click's account's, and credits the latest visit; it answered 404 "Unknown subid for this campaign" for any campaign but that of the visit the database read first
  • Changed: Update CPC (the page, POST /clicks/cpc, p202 click update-cpc) chooses a click a redirector link re-clicked by its first visit, whose time must be in the range and whose campaign, traffic source and other filters must match, and sets the cost of every visit of it. It chose each visit by its own time, so a range holding only a re-click repriced that visit of a click that arrived on another day, and a range holding the first visit left the later visits at the old cost. The check counts such a click once, updated counts the clicks whose cost changed (it counted rows, a re-clicked click once per changed visit), and the reports rebuild every hour a visit of the clicks is in, those after the range included. A click re-clicked before 1.9.79 that the cron job has not yet brought to one visit is taken at each of its visits in the range, and written from that visit on
  • Changed: A conversion's traffic-source pixels and postbacks (gpb.php, upx.php, and the server-to-server postbacks queued for them and for goals) use the traffic source account of the click's first visit, the source the visitor came from, and [[cpc]] is that visit's cost. They read whichever visit the database gave first, which could be a re-click's row with the redirector link's source and cost. A traffic source account of another account (possible on a click recorded by an earlier version) is now no source, and its pixels are not fired; they were
  • Fixed: A click's report row that could not be rolled up, on the tracking link after the click or after a conversion changed the click, was left as it was until its hour happened to be rolled up again for another reason. The click's latest hour is now queued for the cron job, which rolls it up on each run until it succeeds. This also covers the window between replacing the files with 1.9.79's and running its upgrade, in which every click's rollup fails: the cron job takes the queued hours once the upgrade has run
  • Changed: The upgrade to 1.9.79 does not walk your clicks: it notes the newest one, and the cron job then brings every click re-clicked through a redirector before the upgrade to one visit each (every visit takes the first visit's traffic source, cost and filtered and bot flags and, for a click that converted, its lead and value; the latest visit is recorded and the click rolled up again). Each minute's cron run spends about 10 seconds on it (at least one click), reading 5,000 click ids at a time, so a large install takes many runs. Until it reaches a click, the reports count that click at each of its visits, as 1.9.79 did, each under the traffic source and cost the visit was recorded with. A click it cannot bring over is written to the server's error log and the next run starts with it; a click whose visits belong to more than one account is left as it is and logged. It needs the cron job running
  • Fixed: Update › Reset Campaign Subids (POST /conversions/subids/reset, p202 conversion reset-subids) clears a click a redirector link re-clicked with the campaign of its latest visit, where every report counts it and its conversion: it was cleared with the campaign of any of its visits, so a reset of the campaign a click arrived on cleared a sale every report showed under another
  • Fixed: Web events from p202.track() on a landing page were lost (event.php answered 500) between replacing the files with 1.9.79's and running its upgrade; they are recorded, reading each click at every visit until the upgrade has run. An upgrade from 1.9.4 or earlier stopped at 1.9.5 on every run, its report rebuild reading a table a later step makes
  • Fixed: Security. My Account › 3rd Party API Integrations listed the first account's Direct Network Integrations, with their API keys, to every user who could open the page, and never the networks the signed-in user had added. It lists the signed-in user's own networks, and no network's API key reaches the browser: the list says a key is saved and, for a key of 12 characters or more, its last four characters; an edit leaves the key field empty to keep the saved key, or takes a new one to replace it (moving to another network needs that network's key); and the import progress is asked on the server, for the user's own networks. Its progress endpoint now asks for the page's permission. A network edit that is refused comes back to the edit, not to an empty Add a network form. If other users can open that page on your install, get new API keys from your networks and save them there
  • Fixed: Security. My Account › 3rd Party API Integrations wrote the signed-in user's IPQualityScore API key, ClickBank secret key, Zaxaa API signature, Slack incoming webhook URL and JVZoo JVZIPN secret key into each card's field, where the page source, a browser extension or a shared screen showed them, and a refused save wrote back what was typed. Each card says the secret is saved and, for one of 12 characters or more, its last four characters (Saved, ends in …; Saved; Not saved), and its field starts empty: saved empty, it keeps the stored secret and changes nothing (no new ClickBank verification, no Slack notice), and a new value replaces it as before. A stored JVZoo key that cannot be used is not kept by an empty field. A PayKickstart key shorter than 12 characters is listed by its place instead of its last four characters. If someone who should not see these secrets could open or watch that page, replace them at each network
  • New: Account › Settings has a GeoIP database panel: the build date and age of the GeoLite2 City database every location lookup reads (the geo reports, the rotator's country and region rules, privacy for European traffic), read from the file itself, and stale past 30 days. The database Prosper202 ships was built on 2018-07-03, so it shows as stale until it is refreshed. GET /system/info (p202 system info) answers the same as geoip_database, with the refresh as geoip_refresh, and GET /system/health and 202-cronjobs/health.php add a warning while the database is stale, missing or cannot be read (their status stays healthy). A missing or unreadable file is said as such, never with an age
  • New: The GeoIP database can be kept current with your own free MaxMind GeoLite account: enter its account ID and licence key in the GeoIP database panel, and the cron job downloads the new database within the hour and then weekly (MaxMind builds it every Tuesday and Friday); Refresh now downloads it at once. The account ID and key are stored on the install and never shown again, in the page or by the API. A refresh asks MaxMind for the archive's SHA-256 first and stops there when that archive is installed already; otherwise it downloads through the webhooks' outbound guard (https, public addresses only, pinned, never through a proxy), checks the SHA-256, unpacks GeoLite2-City.mmdb into 202-config/geo/, checks that it opens, answers lookups and was built after the installed one, keeps the installed one as GeoLite2-City.mmdb.previous, and renames the new one, written to disk and writable by its owner alone, into place, so a lookup never sees a half-written file. Its requests share five minutes, and a database file that is a symbolic link is left as it is. A refresh that fails leaves the installed database as it was, logs why, shows the reason in the panel, and is tried again a day later. Without an account nothing changes: the shipped database stays in use. 202-config/geo/ gets an .htaccess that refuses every request, and the README's nginx and Apache examples and the Docker image deny the directory, so a downloaded GeoLite database is never served
  • Fixed: A GeoIP database that cannot be opened (missing or damaged) no longer stops a click: the visitor is recorded without a location, and the server's error log says why. It used to throw out of the location lookup, so the click failed
  • Changed: GET /system/dataengine (p202 system dataengine) says how far the cron job has brought the clicks re-clicked before 1.9.79 to one visit each (reclick_backfill: percent, clicks brought over, and what reports show meanwhile), and null once it is done. The walk asks its 10-second budget before each click rather than each 5,000 click ids, so a run no longer holds the cron's other jobs for a minute or more, and a click with very many visits (one re-clicked every minute for months) no longer stops it for good
  • Fixed: Security. GET /users/{id}/preferences (p202 user prefs get and user:prefs:get, and the answer the update commands print) served the account's integration secrets as stored: the ClickBank, JVZoo, Zaxaa and IPQS keys, the Slack webhook, the RevContent id and secret, and the Landing Page Optimizer's keys. It answers each one only as whether one is stored ({"stored": true} or false), and the CLIs print (stored) or (not stored); PUT /users/{id}/preferences still sets them from a string. A column is served only once it is listed as holding no credential, so a column a later version adds is never served by default. A body GET answered can be sent back to PUT whole: its other keys are accepted with the values GET gave them and refused with any other, and a secret's stored state sent back as given leaves the secret as it is (the body was refused for its keys the endpoint does not write). If an API key with access to your preferences was held by anyone who should not have these secrets, replace them at each network
  • New: Clicks record Google Ads' click ids for iOS traffic (gbraid and wbraid) and TikTok's click id (ttclid) beside the gclid, from direct links and from simple and advanced landing pages (the landing page script sends them on from the page's URL), so a Google offline-conversion upload or a TikTok integration can match those clicks. Each is stored as it came, cut to its column as the gclid is: 150 characters for gbraid and wbraid, and 1,000 for ttclid, whose real values run to about 340. The redirector records none of them, as it records no gclid or UTM values. GET /clicks and GET /clicks/{id} serve all four (the gclid too, which they did not serve before), null for a click that came with none, as do p202 click list and click get and the Visitors download; the new [[gbraid]], [[wbraid]] and [[ttclid]] tokens fill them into offer and landing page URLs and traffic source pixels and postbacks. Clicks recorded before 1.9.79 have none. A click recorded between replacing the files with 1.9.79's and running its upgrade is stored without them, and the server's error log names the ids it did not keep
  • Fixed: A gclid or UTM value in the Visitors download that held a tab or a line break, as the visitor sent it, moved every column after it; it is written on one line, in its own column
  • Fixed: Clicks record Microsoft Advertising's click id (msclkid) and Meta's (fbclid) beside the gclid, from direct links and from simple and advanced landing pages (the landing page script sends them on from the page's URL), so a Microsoft offline conversion upload can match a click by its msclkid and Meta's fbc can be built from its fbclid. No version since at least 1.9.55 kept either id: the tables 1.9.51 and 1.9.52 made for them were written by nothing, so [[msclkid]] and [[fbclid]] filled with nothing, in offer URLs, pixels and postbacks alike (this repository's history goes from 1.9.29, which had neither, straight to 1.9.55, so whether 1.9.51 to 1.9.54 kept them cannot be read). A click recorded before 1.9.79 has neither id, and it cannot be recovered. Each is stored as it came, cut to its column as the gclid is: 150 characters for msclkid (Microsoft documents a 32-character id) and 1,000 for fbclid (real ones run to about 200, past the 150 the old table held), and compared case for case, as Meta says its id must be. GET /clicks and GET /clicks/{id}, p202 click list and click get and the Visitors download carry both, null for none; [[msclkid]] and [[fbclid]] fill them into offer and landing page URLs, traffic source pixels and postbacks, and the Setup pages' placeholder buttons offer both. While the database is down, a direct link's cached redirect fills them from the link as it does the gclid, where it left [[msclkid]] and [[fbclid]] in the URL. The redirector records neither, as it records no gclid. A click recorded between replacing the files with 1.9.79's and running its upgrade is stored without them, and the server's error log names the ids it did not keep
  • Fixed: p202 report summary and p202 dashboard with --profiles, --group or --all-profiles added up every field for the TOTAL row, ratios included, so two profiles at ROI 200% and 0% on equal cost showed a TOTAL ROI of 200%. The TOTAL now adds the counts and the money and computes EPC, average CPC, conversion rate, ROI and CPA again from those sums, as GET /reports/summary answers for all the profiles' traffic at once and to the same decimals (0 where a denominator sums to 0). Money in different currencies is never added: GET /reports/summary now answers the account's currency (currency), and when the profiles' currencies differ, or a server before 1.9.79 does not state one, the TOTAL leaves out the amounts and the ratios that need them (EPC, average CPC, ROI, CPA), keeps the counts and the conversion rate, and says what it left out and why: meta.not_totalled and meta.warnings under --json, Warning: lines on stderr otherwise. A field the CLI does not know (a newer server's) stays in each profile's row and is left out of the TOTAL. A profile whose answer holds a count or amount that is not a number is reported as a failed profile, as an unreachable one is, instead of ending the command; a table now says on stderr which profiles failed, and the error when every profile fails names each
  • Changed: An account starts with the privacy setting (Personal Settings, GDPR & privacy; user_pref_privacy) at "Enabled for European Traffic" (eu), where it started at "Disabled": every account made from 1.9.79 on, on a new install and on an upgraded one, whichever way it is made (the installer, Settings › Users, POST /users, p202 user create). Its visitors GeoIP places in Europe get no tracking cookies and are stored with a masked address (IPv4 /24, IPv6 /48), and so does every visitor GeoIP cannot place: a private or reserved address (a test click from your own network included), and every visitor where no GeoIP database is installed. An account that existed before the upgrade keeps the setting it had, Disabled included, and nothing about its visitors changes. The setting in force is the stricter of the install's (the first account's) and the link owner's, so an account added to an upgraded install holds back its European visitors even while the first account is Disabled, and on a new install the first account's setting holds back every account's; set an account to Disabled on Personal Settings (or PUT /users/{id}/preferences, p202 user prefs update --user-pref-privacy disabled) to track its visitors in full
  • Fixed: Get Links built every direct link and redirector link on http://, on an install served only over HTTPS too, so Safari and the ad platforms were handed a plain-http hop, and the redirect's click cookies could not be Secure; GET /trackers/{id}/url and p202 tracker get-url gave the same tracker an https:// link. Get Links' new link, its list of links and the API now build every link alike: on the scheme your tracking domain is saved with (My Account › Personal Settings, https://track.example.com or http://track.example.com), or, saved without one, on the scheme you reach Prosper202 on, which behind a proxy that terminates TLS is the one the proxy reports (X-Forwarded-Proto). So links are https:// unless the tracking domain is saved with http://, or saved without a scheme on an install reached over plain HTTP. Get Links says which and why, with a link to change it. Setup › Postback / Pixel and API integrations read the saved scheme too: they took it from the request alone, so a tracking domain saved with https:// got http:// pixels, postback and notification URLs there while GET /conversions/postback-code and GET /system/integrations gave https:// ones
  • New: An AI assistant connected through MCP (p202 mcp, or Tracking202's hosted MCP server) creates, changes and deletes text ads: the text_ad_create, text_ad_update and text_ad_delete tools run p202 text-ad create, update and delete (a delete previews with dry_run first), and tracker_update's text_ad_id puts an ad on a tracking link. Until now it could only list them, so a set of ads had to be typed into Setup › Text Ads. A hosted connection keeps the access it was approved with: one approved before the install ran 1.9.79, to propose or make changes, reads text ads but answers 403 for these three; connect the assistant again to let it change them. Making an advanced landing page needed nothing new: landing_page_create takes landing_page_type 1 with aff_campaign_id 0
  • Changed: p202 text-ad create refuses, before any request and as Setup › Text Ads does, a campaign's ad (--text-ad-type 0, the default) without --aff-campaign-id and an advanced landing page's ad (--text-ad-type 1) without --landing-page-id, naming the flag and the list command its id comes from; update refuses a change that would leave the ad without the one its type needs (reading the stored ad when a flag sets the type or a link). The API stored such an ad and answered 201, and Get Links never offered it; the API itself now refuses an advanced landing page's ad (text_ad_type 1) that names a simple landing page, naming landing_page_id, since Get Links offers it only on the advanced page it names (an ad stored that way before saves when re-sent as it is). --text-ad-type and --landing-page-type take 0 or 1, as the pages do
  • New: PUT /api/v3/rotators/{id}/rules saves a redirector's whole rule set in one write, as Setup › Redirector's Save does: every rule the redirector is to have, in the order they are tried, written in one transaction or not at all. A rule sent with its id is kept with that id, so its clicks and report rows stay its own (and so do its criteria and destinations sent with theirs); one without an id is added; one left out is removed, which needs the remove_rotator_rule permission, as on the page. What the page refuses is refused: a rule with no criterion or no destination, and a rule with more than one destination where one has no weight or every weight is 0 (the redirects split such a rule's visitors by weight whether or not its splittest, the page's Split test switch, is 1); and so is a campaign or landing page that is not yours, another redirector's rule or entry, or a rule listed twice. Every problem in the set is answered in one 422 naming its place (rules.1.redirects.0.redirect_campaign), and nothing is written. ?dry_run=1 answers the set as it would be stored and each rule's change (added, updated with what changed, kept, removed), writing nothing; the call can be staged (?staged=1); a PATCH is refused (405), since the set is replaced whole. GET /rotators/{id}/rules answers the set in this shape, so a set read, edited and sent back changes only what was edited. p202 rotator set-rules (--rules or --file, - for stdin; --dry-run) sends one
  • New: The MCP tools (p202 mcp and the hosted connector) create, update and delete redirectors and set their rules: rotator_create, rotator_update (its name, or its default destination), rotator_delete (dry_run first) and rotator_set_rules, which takes the whole rule set as JSON, as rotator_get answers it, and writes it as PUT /rotators/{id}/rules does. There are no tools for one rule: a rule is added, changed, moved or removed by sending the set with or without it. A hosted connection with propose or read-and-change access can now change redirectors too (a connection made before keeps the access it was given; connect again for this)
  • Changed: A redirector's rules are tried in an order of their own. They were tried in the order they were created, so a new rule could only go after the others, and one added after a rule that matches everyone never matched; the rule set (PUT /rotators/{id}/rules) now sets the order, the Redirector page saves its rules in the order it lists them, and a rule added on its own (POST /rotators/{id}/rules, p202 rotator rule-create) goes last. The upgrade keeps every redirector's order as it was. Until it has run, the redirect links try the rules in the order they were created, as before
  • Fixed: A rule criterion's values written through the API (POST and PUT /rotators/{id}/rules, p202 rotator rule-create and rule-update) were stored as sent, so "United States(US), Canada(CA)" kept the space before Canada and never matched a visitor from Canada; they are stored as the Redirector page stores them, each value trimmed and the empty ones dropped, and all as ALL, the spelling both redirect links read as every visitor. A device written with a capital (Mobile), which the redirects never match, is refused naming it
  • Fixed: Security. Renaming a traffic source on Setup › Traffic Sources did not check whose source it was, and the rename moved the source into the account of the user who saved it: a user who could open Setup could rename another user's traffic source and take it, with the accounts under it left behind in the owner's account. A traffic source is renamed now only when it is the signed-in user's own and not removed; any other is refused with "That traffic source was not found on this account. Nothing was saved." and nothing is written, and the Slack notice of a rename names the source's stored name, not one from the page's address. The other Setup saves (categories, campaigns, landing pages, text ads, traffic source accounts, redirectors and Get Links) and the legacy WordPress API already checked that the record was the user's; each also names the user in the statement that writes it now. After updating, check each user's Setup › Traffic Sources for a source that is missing
  • Fixed: PayKickstart's IPN URL on Account › API integrations (tracking202/static/paykickstart.php, also listed by GET /system/integrations and p202 system integrations) named a file that did not exist, so every IPN sent there was a 404, and the panel offered it for PayKickstart's Affiliate IPN under "No setup needed", which cannot work: the Affiliate IPN carries no click and no signature. The URL now takes PayKickstart's product (vendor) IPN. Put subid=[[subid]] on the PayKickstart checkout link that is the campaign's offer URL, paste the URL into each product's IPN Integration tab, and store each PayKickstart campaign's IPN secret key on the page (or PUT /users/{id}/preferences paykickstart_ipn_secret_keys, p202 user prefs update --paykickstart-ipn-secret-keys: at most 20, a list that replaces the stored one; a key is never shown back: GET /users/{id}/preferences answers {"stored": true} for them, as for every integration secret, and GET /system/integrations keys_stored says how many). Each IPN is verified with the stored keys (PayKickstart's HMAC-SHA1 hash) before anything in it is read, and recorded for the account whose key verifies it (a deleted account's stored keys verify nothing), on a click of that account: a sale or rebill is a conversion (source paykickstart, a postback) under its transaction_id at its amount, a refund reverses that sale (by its amount, at most the sale's: one stating more takes back the whole sale and its answer says both, or in full when it states none), and a test-mode IPN, subscription-payment (sales already counts every payment) and every other event record nothing. Every answer is a status PayKickstart's IPN log shows, with the reason: 200 recorded, reversed, duplicate, test, ignored or no_click (the checkout carried no subid); 400 a field that cannot be read, named; 403 a hash no stored key verifies; 404 no such click in that account, or no such sale to reverse; 409 a retry under a transaction id on file that states another amount, or a refund of a sale already reversed by another amount (a full refund after a partial one included); 415 the Affiliate IPN's JSON; 503, naming the upgrade, while the database's upgrade has not run. Each IPN is logged in one line, never with a key or the buyer's details. An affiliate's Affiliate IPN still cannot update conversions, and the panel now says so
  • New: Every conversion records how its click was found (match_method): click_id when the request named the click (a subid or sid on a pixel or postback, ClickBank's tracking code, the click id of POST /conversions, POST /events or an Update Subids or revenue upload line, an app install's signed token), cookie when a tracking cookie in the visitor's browser named it (gpx.php and upx.php with no subid, px.php), visitor_id for a landing page's p202.track() event (the click the page's visitor id was last seen on), same_ip when nothing named a click and it is the account's latest click from the visitor's address in the last 30 days (gpx.php, upx.php and px.php with neither a subid nor a cookie: a guess), and unknown for a conversion recorded before 1.9.79. A goal's conversion carries how the event that reached it found its click, also when a replay or a re-evaluation writes it later. GET /conversions and GET /clicks/{id}/conversions (p202 conversion list, p202 click conversions, and the "Click found by" column of a click's breakdown in Visitors and Spy) show it; GET /conversions takes match_method= (p202 conversion list --match-method same_ip); GET /conversions/match-methods (p202 conversion match-methods) counts each campaign's conversions, the ones that count and their income by method; and Group Overview has a Match method level beside Goal / source. The upgrade adds the column to 202_conversion_logs and 202_goal_events, where every row already there reads unknown. A conversion recorded between replacing the files with 1.9.79's and running the upgrade is stored without it, reads unknown, and logs that the upgrade has not run
  • Fixed: A traffic source's server-to-server postback (a Postback URL pixel on Setup › Traffic Sources, and its correction URL) is sent only to a public address. The server called whatever the pixel named, so anyone who could edit a traffic source account could have it call this server's own network or a cloud metadata address (169.254.169.254), and anyone who knew a click id could set that off through gpb.php. Each postback is now checked when it is sent, by the export webhooks' guard: its host is resolved, and a private, loopback, link-local, carrier-grade NAT, multicast, reserved or other non-public address, IPv4 or IPv6 (an IPv4 address inside IPv6 included), is refused; the connection goes to the address that passed, with no proxy, and each redirect (at most 5, as before) is checked the same way before it is followed, so a network's redirect to an internal address is not. http:// and any port are still allowed. A refused postback is not sent and not retried: its outbox row is failed with the reason, the error log names the pixel and the host but never the URL's query, which can hold the network's key, and gpb.php and upx.php answer as before. Saving a Postback pixel through POST or PUT /ppc-accounts/{id}/pixels (p202 ppc-account pixel create/update) or Setup › Traffic Sources refuses, naming the field, what shows without a lookup: such an address in the URL, a local name (localhost, .local, .internal), a user name or password, a scheme other than http(s); a pixel already stored is not refused when saved again as it is, and is refused when sent. An operator whose receiver really is on their own network admits it with P202_WEBHOOK_ALLOW_NETWORKS in 202-config.php, the export webhooks' setting; metadata and link-local addresses are never admitted. A postback reads at most 64 KB of the answer, and a failed one's last_error says what the network answered rather than repeating its URL
  • Fixed: JVZoo's IPN URL on Account › API integrations (tracking202/static/jvzoo.php, also listed by GET /system/integrations and p202 system integrations) named a file that never existed, so every JVZIPN sent there was a 404. JVZoo now has two URLs, both verified with the account's JVZIPN secret key: ?role=affiliate for an affiliate's Affiliate IPN (Affiliates › Approved Products › Integrations), where the click is the tid=[[subid]] on the JVZoo affiliate link that is the campaign's offer URL (caffitid), and ?role=vendor for a product's JVZIPN v1 URL, where the click is the subid=[[subid]] on the buy link (cvendthru); each role reads only its own field, since the other is the other party's choice. The panel gives both sets of steps. Each notification's cverify (JVZoo's published algorithm, compared exactly) is checked with every account's stored key before anything in it is read, and it is recorded for the account whose key verifies it (a deleted account's stored key verifies nothing), on a click of that account: SALE and BILL are a conversion (source jvzoo, a postback) under ctransreceipt at ctransamount, dated by ctranstime (a time that is not a Unix time is refused); RFND, CGBK and INSF (an eCheck chargeback) reverse that sale in full; CANCEL-REBILL, UNCANCEL-REBILL and TEST record nothing. Only the fields JVZIPN v1 documents are taken, an amount without a decimal point is refused (JVZoo's table says pennies, its notifications state dollars), and JVZIPN v2, whose cverify covers neither the amount nor the tid, is answered 415. Every answer is a status JVZoo retries on, with the reason, as PayKickstart's are; a SALE sent again after its refund (JVZoo's way of undoing one) is a 409 naming the reversal to delete to count the sale, and a refund sent after that reverses it again (the same refund sent again unchanged is a duplicate). The key is stored trimmed, and the page and PUT /users/{id}/preferences refuse one with a line break or a control character, which no notification would verify with; GET /system/integrations secret_stored says whether a usable key is stored
  • Fixed: LTV webhooks (LTV Settings, POST /ltv/webhooks, p202 ltv webhooks), the Landing Page Optimizer's among them, were queued and never sent on an install whose cron job runs only 202-cronjobs/index.php, which is every default one (the installer's cron line, install.sh, Docker, AutoCron): only 202-cronjobs/ltv_webhooks.php sent them, and nothing scheduled it. The minutely cron job now sends what is due after its other work, oldest first, for up to 15 seconds a run. Retries are as before, 2, 4, 8, 16 and 32 minutes apart, and the 6th failed attempt fails the delivery and marks the webhook dead; a receiver that takes 2 seconds or more to fail is tried once a run, so it does not hold up the other webhooks. A delivery still unsent 7 days after it was queued is not sent: the delivery log (GET /ltv/webhooks/{id}/deliveries, p202 ltv webhooks deliveries) shows it failed, with 0 attempts and "expired: …" as its last response. So after the upgrade a webhook is sent the events of the last 7 days that were waiting, oldest first, and the older ones expire unsent; a receiver should de-duplicate by X-P202-Delivery, since a delivery whose outcome could not be recorded is sent again. 202-cronjobs/ltv_webhooks.php is the same run on its own (--budget=N), which docker-compose.coolify.yaml's worker service now runs; the two never run at once
  • Fixed: ClickBank's Instant Notification Service (tracking202/static/cb202.php) read the secret key and the Slack webhook of account 1 only, so no other account could use it, and it decrypted with AES-128, where ClickBank's INS article says notifications are encrypted with CBC-AES-256 and its PHP and Python samples decrypt them so: a notification encrypted as the article says did not decrypt for any account. Each notification is now decrypted with every account's stored ClickBank secret key (Account › API integrations › ClickBank, 202_users_pref.cb_key, AES-256-CBC keyed with the first 32 hex digits of the key's SHA-1) and recorded for the account whose key decrypts it (a deleted account's stored key decrypts nothing), on a click of that account: a SALE is a conversion (source clickbank, a postback) under its receipt at its totalAccountAmount, dated by its transactionTime read with the offset it carries (one ClickBank sends late still falls on the day of the sale; one that cannot be read is a 400), and TEST marks that account's key Verified and posts to that account's own Slack webhook, once ClickBank has its answer. A key that two accounts store is refused (409), since it cannot say whose sale it is, and the server's log names the accounts. Every answer is a status ClickBank's INS log shows, with the reason: 200 recorded, duplicate, test, ignored (every other type, as before) or no_click (a sale with no tracking code, which was a 400 that ClickBank sent again five times); 400 a field that cannot be read, named (a tracking code such as 42.9, an amount such as 1,000.00); 403 a notification no stored key decrypts; 404 no such click in that account; 409 a retry under a receipt on file that states another amount; 415 a legacy notification (version 4.0 or earlier, signed with cverify), saying to choose version 8.0; 422 an account that counts in a currency other than USD, since ClickBank's amounts are USD; 503, naming the upgrade, while the database's upgrade has not run. A notification that decrypts to something other than an order answers as one no key decrypts: the two were different 400s, which is what a padding-oracle attack on the cipher needs. Each notification is logged in one line, never with a key or the buyer's details
  • Fixed: LTV webhooks (LTV Settings, POST /ltv/webhooks, p202 ltv webhooks), the Landing Page Optimizer's among them, are sent only to public addresses, by the export webhooks' guard and sender. Their own guard approved Azure's platform endpoint 168.63.129.16 and the IPv6 spellings of loopback and the cloud metadata address (::ffff:127.0.0.1, ::ffff:169.254.169.254, ::ffff:a9fe:a9fe, 64:ff9b::a9fe:a9fe) and connected to them, so a webhook whose host was or resolved to one of these reached the metadata service or this server itself. A webhook is now refused when it is saved (a 422 on url), and each delivery when it is sent, for a private, loopback, link-local, carrier-grade NAT, metadata or other non-public address in any spelling, an IPv4 address inside IPv6 judged as that address; every address the host resolves to is checked, the connection goes only to the one that passed, with no proxy and no redirects, and a refused delivery is logged "blocked: …" with the reason and nothing is sent. A webhook saved by an earlier version with such an address, or with a URL that is not plainly spelled (a user name before the host, a fragment), is refused when it is sent. A delivery reads at most 64 KB of the answer, where it read all of it. The URL must still be https on port 443 or 8443. P202_WEBHOOK_ALLOW_NETWORKS in 202-config.php, the export webhooks' setting, now also admits an LTV webhook receiver on the operator's own network; link-local and metadata addresses are never admitted. The export webhooks and the traffic-source postbacks now also refuse, under any allowlist, a link-local or metadata address carried in IPv6 as IPv4-compatible, IPv4-translated, local-use NAT64, 6to4 or Teredo, which an allowlist entry covering those IPv6 ranges admitted
  • Changed: Guessing a conversion's click from the visitor's IP address is an account's setting, Match conversions by IP address (Personal settings › Advanced; user_pref_same_ip_fallback on PUT /users/{id}/preferences; p202 user prefs update --user-pref-same-ip-fallback and bin/p202 user:prefs:update --user-pref-same-ip-fallback, 1 or 0). When a pixel (gpx.php, upx.php, px.php) names no click, neither by a subid nor by a tracking cookie, it credits the account's latest click from the visitor's IP address in the last 30 days, a guess that is often wrong on iPhones (iCloud Private Relay) and mobile networks, where many visitors share one address. The default changed for new accounts only: an account made from 1.9.79 on, the first account of a new install included, starts with it off, and every account that existed before the upgrade keeps it on, so its numbers do not change. To stop the guesses on an existing account, choose Off there or send user_pref_same_ip_fallback 0. A visitor who is given no p202vid cookie is never matched by address either: one the privacy setting holds back (under privacy the fallback credited the one click from the masked address), one whose pixel request carries a p202_consent other than 1, and one on a campaign whose identity capture is off. Every conversion it finds is recorded as same_ip
  • Fixed: The global pixel (gpx.php) and the universal pixel (upx.php) guessed a click from the IP address among the install's first account's clicks, whichever account's pixel it was: another account's pixel that named no click never converted that way, and could convert the first account's click. They look among the clicks of the account whose campaign the pixel names (cid, as the advanced pixel carries it); a pixel that names no campaign looks among the clicks of the install's one account with campaigns, and guesses nothing on an install where several accounts have campaigns (logged; use the advanced pixel)
  • New: GET /reports/breakdown says when rows remain after the page it answers: beside data it carries truncated (true when there are more rows), limit and offset, as GET /reports/timeseries says truncated. A page held 50 rows unless limit asked for more (500 at most) and said nothing of the rest, so the rows of one page read as all of them. p202 report breakdown and p202 analytics pass the three keys through under --json, stamp truncated on every --ndjson line, and under a table print one note on stderr naming the --offset of the next page (a client-side --min-clicks, --min-cost, --zero-leads or --having filtered only that page, and the note says so); bin/p202 report:breakdown prints the same note. p202 report breakeven takes --limit and --offset, reads one page as report breakdown does and ranks it by margin, and says the same. p202 report losers, report winners and campaign optimize read one page of 50 and classified or ranked only those, so a keyword or source past the first 50 by clicks was never listed: they read page after page, 500 rows a request, up to the 5000 with the most clicks, and their --json says truncated: true when the answer covers only those. p202 report crosstab ranked the first 50 rows by clicks and showed the 20 with the highest --metric among them, and read the first 50 columns of each: the server now ranks the rows by --metric over every row, each row's columns are read in full up to 5000, and truncated (with a note) says when --limit-rows or that bound left rows out. The MCP tools report_breakdown, report_breakeven and report_crosstab answer with truncated and say in their description what it means. campaign optimize no longer drops a failed read of the other campaigns (it answered with no alternative); it fails, naming it
  • Fixed: An LTV subscription that no renewal reaches (POST /ltv/subscriptions, p202 ltv subscription upsert) is marked past_due once its current_period_end and grace_days are over, and canceled 30 days after that, by the cron job's hourly run; it was done only by 202-cronjobs/ltv_maintenance.php, which no default install scheduled, so the subscription stayed active for good, in MRR (GET /ltv/mrr, p202 ltv mrr) and the customer's rollups, and never in churn. A subscription canceled this way is dated when the rule cancels it (period end + grace + 30 days), not when the cron got to it, so after the upgrade the subscriptions that lapsed while nothing ran go, oldest first, 500 at a time for up to 20 seconds an hour, and one that lapsed long ago counts in this quarter's churn only if that date falls in its 90 days. The daily run recomputes the customer rollups from the ledger for the customers active in the last 48 hours (it read the whole ledger for every 500 customer ids, a minute for 3,349 customers of 100,000, and now takes under a second), rebuilds the next-offer transitions, attaches customers to companies by name and removes expired personalization tokens. 202-cronjobs/ltv_maintenance.php runs it all at once (--full, --batch-size=N, and --budget=N seconds, 300 by default) and refuses an option it does not know, where it ran with defaults; it and the cron never run at once
  • Fixed: GET /reports/groups (p202 report groups) divided each group's sums in floating point and answered its ratios unrounded, so the same traffic had two answers: one lead in three click-throughs was a conversion rate of 33.33333333333333 there and 33.3333 in GET /reports/summary and GET /reports/breakdown, and an EPC of 1.3333333333333333 against 1.333333333. Every group at every level, and the totals, are now computed by the database with the formula the summary and the breakdown use and to the same decimals (EPC, average CPC, ROI and CPA to 9, conversion rate to 4), so a group answers what they answer for the same rows to the last digit
  • Fixed: p202 report breakeven replaced the server's conversion rate (conversions ÷ click-throughs) with conversions ÷ clicks, and rounded the average CPC to 4 decimals, so on landing-page traffic a keyword at a 50% conversion rate in p202 report breakdown showed 25% there, and a $0.00125 CPC showed $0.0013. Every figure the breakdown shows is now the same in breakeven; the rate break-even prices a click with is its own column, click_conv_rate (conversions ÷ clicks × 100), and breakeven_cpc, margin and the verdict are computed as before
  • Fixed: ClickBank's rebills were never recorded and its refunds and chargebacks never took a sale back: cb202.php acted on TEST and SALE only and answered every other notification 200 with nothing done. Now a BILL (a subscription's rebill) is another conversion on the click under its own receipt (the first payment's with -0001 after it) at its totalAccountAmount; an RFND (a refund), a CGBK (a chargeback) and an INSF (an eCheck the buyer's bank returned) reverse the conversion under their receipt, by what totalAccountAmount says the account lost (ClickBank refunds part of a payment too) and at most what the payment recorded (one stating more takes back the whole payment and its answer says both), or in full when it states none or 0; a payment is reversed once: ClickBank's retry of a reversal answers 200 duplicate, and any other reversal of a payment already reversed (a second partial refund, or a chargeback of a refunded payment), whatever amount it states, is refused (409), naming the reversal on file and how much is still credited; a reversal of a receipt not on the click is a 404. CANCEL-REBILL and UNCANCEL-REBILL (a subscription cancelled, a cancellation undone), SUBSCRIPTION-CHG, ABANDONED_ORDER and ClickBank's customer notifications move no money and answer 200 ignored, each logged with its account; TEST_SALE, TEST_BILL, TEST_RFND and the test cancellations are tests (200, the key marked Verified). A type ClickBank's INS article does not list is refused (400) naming it, rather than acknowledged and lost. For a subscription, set the campaign's When a click converts more than once to Add them up, or each rebill replaces the click's value
  • New: A dead LTV webhook can be revived: LTV Settings has a Revive button on its row, PUT /ltv/webhooks/{id} takes {"status": "active"}, and p202 ltv webhooks revive (bin/p202 ltv:webhook:revive) does the same. A webhook is marked dead when one delivery fails 6 times, about an hour of retries, and nothing could make it active again: the page said to register it again, which for the Landing Page Optimizer meant pairing again. A revived webhook keeps its id, URL and secret, so a receiver checks signatures as before and the optimizer's pairing goes on; every delivery of it that was not delivered and was queued in the last 7 days is sent, oldest first, each with its own X-P202-Delivery, never a delivered one: again for the failed and held ones, and the ones still pending with their attempts back at 0 ("requeue": false, --requeue=false, sends new events only, and holds the ones still pending too); and a revival waits for a cron run that is sending, so it never changes a delivery mid-send (one still sending after 20 seconds is a 409, with nothing changed). While a webhook is dead its events are queued and held, never tried: the delivery log (GET /ltv/webhooks/{id}/deliveries, p202 ltv webhooks deliveries) shows them failed with "held: …" as the last response. Before, a dead webhook was queued nothing, and the deliveries it had pending spent their remaining attempts on "endpoint not active"
  • Fixed: POST /rotators/{id}/rules and PUT /rotators/{id}/rules/{ruleId} (p202 rotator rule-create and rule-update, rotator:rule:create) wrote a rule with no criterion, answering 200: rule-create without --criteria-json sent none, and {"criteria": []} cleared them. The redirect links read a rule with no criterion as every visitor, so one call sent all of a redirector's traffic to that rule's destination, while the Redirector page and the rule set refused such a rule. The two calls now check a rule as the rule set does, with the same 422 in the same words: a criterion (the value ALL matches every visitor, said in so many words), a destination, and a weight on each destination of a rule with more than one, at least one above 0. A PUT keeps what it does not send, and the rule it would leave is checked whole, so a rename of a rule stored with two destinations both weighted 0 is refused. The CLIs refuse a rule-create with no criterion or no destination, and an empty list, before any request. Rules already saved with no criterion are left as they are and the redirect links still send every visitor through them, as before; GET answers them with "criteria": [], the Redirector page lists them as "If any visitor (no criteria)", and a write that would leave one with none, a rename included, is refused saying so. The Redirector page's save refuses a rule posted with no criteria list as a plain refusal; it answered with PHP warnings before the refusal
  • Fixed: Security. A sync (POST /sync/jobs, /sync/re-sync and /sync/plan, p202 sync and p202 diff) calls its two profiles' URLs only at public https:// addresses. The server called any URL a profile named, with the profile's API key, and read back what answered, so a key with sync access could have it read this server's own network or a cloud metadata address (169.254.169.254). Each request a sync makes is now checked when it is sent by the export webhooks' and postbacks' guard: its host is resolved, and a private, loopback, link-local, carrier-grade NAT or other non-public address, IPv4 or IPv6, is refused; the connection goes to the address that passed, with no proxy, and a redirect is not followed (the key goes only to the URL named; the sync fails naming where it pointed). A profile URL with a query, a fragment, a user name or password, or localhost is refused, and POST /sync/jobs, /sync/re-sync and /sync/plan refuse what they can tell without a lookup with a 422 on source.url or target.url, before anything is queued. Two installs on one machine or one private network sync once the operator of the install that runs the sync admits the other's address with P202_WEBHOOK_ALLOW_NETWORKS in 202-config.php (write it as http://127.0.0.1:8001, not localhost), which also allows http:// there; plain http:// to any other address is refused, since it would carry the key in plain text, and link-local and metadata addresses are never admitted. On the Docker stacks, P202_URL_MAP's service names are accepted and their network needs the same setting. A sync reads at most 8 MiB of one answer and 200 pages of 500 records of one list, and takes at most 900 seconds in all; it read until a remote stopped. A cap reached, or an answer that is not a page of records, fails the sync naming the cap and the remote, and is never read as a shorter list, which a prune would act on. A refused URL or a cap stops the whole sync, skip_errors or not, fails its job at once with error_code sync_url_refused or sync_limit (a host that does not resolve, sync_url_unresolved, is retried), and the job's written and the end of its error say what the target had confirmed writing by then. A queued job keeps both profiles' API keys encrypted (AES-256-GCM, under a key kept in the database) where it kept them as sent, and a job that has ended keeps none; a job queued before 1.9.79 still runs, and the sync worker removes the keys from every job that ended before it. p202 sync stops on a refused profile URL with a hint naming the profile to repoint and the setting, and does not run that sync from your machine instead
  • New: An install admin can find and move back the traffic sources the rename took before 1.9.79. GET /system/traffic-source-owners (p202 system traffic-source-owners show) lists every traffic source that another user's traffic source accounts name, with the user it is in now, each user whose accounts name it, and on their accounts the traffic source accounts, clicks and conversions. POST /system/traffic-source-owners/repair (p202 system traffic-source-owners repair , or --all) moves a source to the user whose accounts name it, one transaction per source, queues those accounts' clicks for the report rollup, and logs the move; only the source's user changes, its name stays as the rename left it. Nothing is moved by itself, the upgrade included: the same rows also come from traffic source accounts the API put on another user's source before 1.9.77 and from moves made on purpose, so the report marks a source ambiguous when its own user has accounts on it too, when several users' accounts name it, when an account was saved after the source was (or in the same second), or when a user no longer exists or is deleted, and an ambiguous source moves only to a user the operator names (to_user_id, --to-user-id), never to a deleted one. Both need an Admin with access_to_settings; a repair also needs a user who may act on both users, so only user 1 moves a source into or out of user 1's account. A repair can send back the owner and repair_to_user_id the report showed (the CLI always does, and its --all repairs the sources it showed one at a time), and a source that changed since is refused and left as it is. A source taken with no traffic source account under it leaves no sign and is not listed
  • New: Offline conversions for Google Ads and Microsoft Advertising (Setup › Offline Conversions). The README has long promised "offline conversion tracking with one-click CSV export", and until now there was only a gclid column in the Visitors download. The account's conversions are written as each platform's offline conversion import file: Google Ads Data Manager's (Google Click ID, GBRAID and WBRAID, every id a click came with), Google's classic Conversions › Uploads template (a Parameters:TimeZone line and the gclid), or Microsoft Advertising's (the msclkid, times in UTC, Microsoft's 100,000 rows an upload). Each line carries the conversion name of its campaign's Google conversion action or Microsoft goal (one per campaign, and one for the account; set on the page), the conversion's time in the account's time zone with the offset in force at that instant, the value it counts for net of partial refunds in the account's currency, an Order ID (P202- and the conversion's id, the same on every fetch, so Google counts a conversion sent twice once) and, for Google, the Ad User Data and Ad Personalization columns, stated per file (Prosper202 records no visitor's consent and infers none). Google and Microsoft each count one conversion per click and second, whatever its Order ID, so of two conversions on one click in one second the one recorded first keeps its time and the other is sent at the next second no conversion on that click holds, the same second on every fetch. Only a conversion whose click was named is sent (match_method click_id or cookie): a click found by a visitor id or guessed from the visitor's IP address would teach the platform's bidding the wrong clicks. A conversion is also left out when it no longer counts, when its click came with no id from the platform, or is more than 90 days or less than 6 hours old, or has no conversion name; every one left out is counted under its reason on the page, by GET /conversions/offline-export/summary and by p202 conversion offline-export --summary. The file is the page's download, GET /conversions/offline-export (format google, google_uploads or microsoft; days 1 to 90; campaign_id; conversion_name; ad_user_data and ad_personalization; every other parameter refused by name) and p202 conversion offline-export. A fetch URL made on the page lets the platform fetch the file on its schedule, so the upload runs itself (Google Ads' Uploads schedules or Data Manager's HTTPS source; Microsoft's Schedules): the URL is shown once, only its key's SHA-256 is kept, it can be replaced or deleted, and it stops answering when its account is deactivated or its role no longer reads campaign data. A refund or a deletion after the platform has a conversion is not sent to it; adjust it in the platform
  • Fixed: A redirector rule whose criterion was stored as "all" (the API stored values as sent before 1.9.79, and the Redirector page stores what is typed) sent every visitor to the rule from a redirector link (rtr.php) and every visitor to the default from a landing page's offer link (offrtr.php), which read only "ALL". Both now read a criterion's value one way (Prosper202\Rotator\Criterion): each value trimmed, and ALL in any case, alone or among other values, matches every visitor whatever the statement. So a rule stored as "all" sends offer-link clicks to the rule, and one stored as "All", " all " or "United States(US), all", which neither link read as every visitor, now sends every visitor to it from both. The writers store ALL (the Redirector page's save, which stored "all" as typed, included); the Redirector page and Overview > Rotator Breakdown list such a criterion as "any visitor"; p202 rotator test predicts the same destination as the links (it read "All" as every visitor but missed "United States(US), all"), and p202 diff no longer reports "all" and "ALL" as a difference between two installs. A value with a space beside a comma ("United States(US), Canada(CA)", stored as sent by the API before 1.9.79) now matches its second country. A value holding ALL is taken whatever the criterion's type: an IP criterion of ALL, which both links read as every visitor, was refused by the API and the Redirector page as "not an IP address", so a redirector holding one could not have its rule set sent back as GET answered it; and the API no longer refuses a country or device list beside ALL ("US, ALL") for its form
  • Fixed: p202 report losers and p202 report winners rounded the cost to 2 decimals and the average CPC to 4 and showed them under the breakdown's own names, so a keyword that spent $0.00375 read total_cost 0 and a $0.00125 CPC read 0.0013, where p202 report breakdown shows the same rows' figures as they are; each cell of p202 report crosstab was rounded to 2 decimals, so --metric avg_cpc read 0 for any CPC below half a cent; and p202 campaign optimize rounded each offer's epc to 3 decimals. total_clicks, total_leads, total_cost, total_net and avg_cpc are now the breakdown's, unchanged (only the table view rounds, as it does for every report), and a reason names an amount below a cent as the table shows it, not as $0.00. With --payout, losers and winners wrote their own profit (sales × payout − cost) over total_net; it is now its own column, payout_net, which the rows are classified and sorted on, and total_net is the breakdown's
  • Fixed: LTV Settings reads the row a button names exactly. Delete read the webhook, field or integration id with a cast, so an id sent as 12abc deleted webhook 12: one of the account's own, and not the one named. An id that is not exactly a row's (12abc, 0, -1, an empty one) is refused now with "Pick the webhook to delete from the list." (or the field, or the integration), and so is one sent to open a webhook's delivery log. The page's other fields are read the same way: the customer c-param must be Off or c1-c4 (1abc was read as c1), a custom field is required only when its box sends 1 (false made it required), a field sent as a list is refused (an integration's provider was stored as "array"), and a webhook registered with no event ticked is refused, where it was subscribed to every event
  • Fixed: p202 campaign optimize (and the MCP tool campaign_optimize) printed conversions ÷ clicks as the campaign's "CVR", where every report's conv_rate is conversions ÷ click-throughs, so on landing-page traffic a campaign at a 50% conversion rate in p202 report summary and report breakdown read 20% there. The diagnosis now gives the reports' conv_rate and names its own rate as conversions per click ("20.0% of clicks"), the one its break-even CPC (payout × conversions per click) is computed from, as before
  • Fixed: My Account › API Integrations shows the Landing Page Optimizer as Connected only while the webhook its pairing made is active. It read Connected from the pairing alone, so when that webhook was marked dead (one delivery failed 6 times) or deleted in LTV Settings, the optimizer stopped receiving conversions and engagement with nothing said. The card now reads "Not receiving events" and says why: a dead webhook names LTV Settings, where it is revived once its endpoint answers again; one that no longer exists, or is switched off, is paired again with Disconnect and Connect. A pairing record that names no webhook, or a webhook whose status cannot be read, reads "Status unknown", never Connected
  • Fixed: The cron job (202-cronjobs/index.php) runs one at a time, however long a run takes. Its lock was a file it made and deleted: two runs starting together could both find it absent, and once the file was ten minutes old the next run deleted it as stale and ran beside the one still going, so a run longer than ten minutes had the minute and hourly jobs running twice at once; a run that died left the file, and every run for the next ten minutes (the pages' pings, a cron job's, AutoCron's) did nothing. The lock is now one the system holds for the run (flock() on 202-cronjobs/cron-run.lock) and releases when the run ends or its process dies, with no age. A run that finds it held does nothing, records no run, and writes "Cron job already running: another run holds …" to the server's error log. Every way the cron is run takes it: a signed-in page's ping, a cron job's curl or php line, the Docker cron services and AutoCron. The web server's user and the user a crontab's php line runs as must both be able to open the file: the run that makes it leaves it readable by every user, and a run that cannot make, open or lock it stops with the reason (exit 1 on the command line, 500 over HTTP, and 202-config/cronjob.log) instead of running without a lock. So an install whose web server cannot write 202-cronjobs/, whose cron ran without a lock until now, needs that directory writable once, or an empty cron-run.lock made there that every user can read. A run that hangs (a query waiting on a lock, say) now holds the cron until its process ends, where after ten minutes a second run started beside it; each run it turns away says so in the error log. health.php's isLocked says whether a run holds the lock now (it said whether the file existed, and null now means the file could not be read), and its lockAge and lockAgeFormatted are gone: the lock has no start time. A cron.lock an earlier version left in 202-cronjobs/ is no longer read and can be deleted
  • Fixed: A subscription the cron job's hourly sweep moves to past_due or canceled queues the subscription.changed webhook event, as the same change through the API does (action upsert for past_due, as POST /ltv/subscriptions with that status queues it; action cancel for canceled, as the cancel event does), in the same transaction as the change. The sweep queued none, so a CRM or the Landing Page Optimizer subscribed to subscription.changed never heard that a subscription lapsed. Only a change that took effect in the last 7 days is announced, the time the sender keeps an unsent delivery: an install whose sweep never ran before 1.9.79 hears of the last week's lapses after the upgrade, not of every subscription that lapsed since it was recorded (on a test install with 30,000 lapsed over two years, 561 events rather than 30,000 for each webhook, which would have held every live event behind them for hours). The run's line says how many changes it did not announce
  • Fixed: A request refused for a scope its API key lacks says which scope and what grants it. A key keeps the scope it was made with, so an AI app connected through Tracking202's hosted MCP server before a release let connections do more is refused there (403) until it is connected again: the answer named the scope only in its prose, and the CLI's hint, the one an assistant passed on, was to mint a key with p202 user apikey create --scope write, which a hosted connection cannot use. The 403 now ends its message with the remedy and adds code (insufficient_scope), required_scope, key_scope, key_source (ai_connection for a key an AI app's connection made, api_key for any other, unknown when the key could not be read) and remedy, and for a connection connect_access: the access to choose on the approval page, never less than the connection has now, or null when no connection's key can hold the scope (users, system, sync). For a connection the remedy is to connect the AI app again and choose that access; for any other key, a new key whose scope includes the missing one, since a key's scope cannot be changed. The Go CLI's hint (exit 2) names the scope and either p202 user apikey create --scope with this key's scope and the missing one, or connecting the app again; on the hosted MCP server every scope refusal says to connect again, and from an install older than 1.9.79 the hint reads the scope from the message. p202 mcp and the hosted MCP server hand the assistant that message and hint. A refusal for the user's role answers as before
  • Fixed: The LTV maintenance job (202-cronjobs/ltv_maintenance.php) attaches customers whose company was stored before that company existed, 2,000 per account per run, and it took the same 2,000 first on every run. A company of nothing but spaces (tabs, no-break or ideographic spaces) names no company and was skipped and left as it was, so an account with more than 2,000 of them never had another customer attached. The job now goes through each account's customers in the order they were created, past those, and stores such a company as empty, once, as clearing the company on the customer's page would: the customer is no longer listed under a blank company in the LTV report's Company (ABM) view or GET /ltv/abm, GET /ltv/customers reads its company as "", and its email domain still does not attach it to a company. A company the database gives no id for is now reported as an error, by the job and by a customer save (which is refused), where the customer was left unattached and the save answered as done
  • Changed: GET /ltv/customers and GET /ltv/customers/{id} (p202 ltv customers) answer a customer's money as JSON numbers, as GET /ltv/summary has since 1.9.77: total_revenue, refunded_amount and mrr, and in the detail each subscription's amount and mrr, each revenue event's amount, and each line item's quantity, unit_price and amount (a unit_price never sent stays null). They answered MySQL's decimal strings ("54.12500", a quantity of "1.500"), so one field read as a number from one endpoint and as text from another. So do the answers that show a customer (POST and PATCH /ltv/customers, a merge, a new alias, an erasure's dry_run record), and GET /ltv/customers/{id}/engagement answers each campaign's conversions as an integer ("1" before) and each event's value as a number or null. A customer's custom_fields is an object whether or not it holds a value: with none it was [], a list. GET /ltv/customers/{id}/next-offer already answered numbers. A client that compared these fields as strings now gets numbers; the Go CLI and the PHP CLI read either
  • Fixed: Security. The container image (the Dockerfile the Coolify and production docker compose stacks build) no longer ships the test suite. Its tests/ folder was copied into the web root, and some of its scripts, the integration tests' helpers, act on a database with no sign-in: a request for one installs the schema, another runs the upgrade, others delete a user or a conversion, each on the database its environment or its defaults name (root with no password on the server's own MySQL, database prosper202). On the image they reached no database unless one answered there, and showed a PHP error naming the server's paths. A checkout served as the web root (the development docker compose stack, a clone on a web server) still holds tests/, and every such script now answers a web request with 404 before it does anything. Rebuild the image (Redeploy in Coolify) to take the change
  • Fixed: PUT /rotators/{id}/rules/{ruleId} with redirects (p202 rotator rule-update --redirects-json) deleted the rule's destinations and inserted them again, so each took a new id, and the clicks it had sent, which name it by id, were listed under no redirect in Group Overview's Redirect level. It writes them as the rule set (PUT /rotators/{id}/rules) writes a rule it keeps: a destination sent with its id, as GET answers it, keeps the id and is changed in place; one the list leaves out is removed; one sent without an id is added. Its criteria are written the same way. An id that is not one of the rule's own destinations (another rule's, or another account's) is refused, as before. Clicks already listed under no redirect after an earlier update stay so: the destination they named is gone
  • Fixed: Customer LTV's pages read the record a button or the merge dialog names exactly. Companies' Merge read the company kept and the one merged away with a cast, so an id sent as 12abc merged company 12: one of the account's own, and not the one named. So did Companies' Edit and Delete, Products' Save and Delete, and every action on a customer's page (Erase, Merge, Link Identity, Unlink, Save Changes), whose customer id was read the same way, as was a customer opened by ?customer_id=. An id that is not exactly a record's (12abc, 0, -1, an empty one, or none) is refused now with a sentence naming what to pick ("Pick the company to merge away from the list.", "Pick the customer from the Customer LTV list."), and nothing is changed; the merge dialog's search answers the same way. The pages' other values are read exactly too: a page the pager did not write, a grouping, segment or status the list does not offer, and an unknown action are said, where they were read as the first page, the default or nothing; a field sent as a list is refused, where a name or an identity was stored as "Array"; and a customer's Save Changes is refused when its fields come as one value or one is left out, where every field so sent was cleared
  • Fixed: Android installs waiting for their click (pending_click) are settled in turns, so installs that cannot be settled no longer stop the rest. The cron job took the oldest 500 every minute and left an install whose settle failed where it was, so 500 installs whose stored token no longer verified (the install-token key had been replaced after they arrived) or whose stored body no longer read were the whole batch on every run, and no other app's install was settled again; installs still waiting for a click that never came held the batch the same way for up to a day. A failed settle is now tried again on the next run, then after a minute, doubling to an hour. An install whose own stored token or body cannot be read is settled as bad_token 24 hours after it arrived, the deadline a missing click already had, with the reason in its match_reason; a missing key, a database error or a lock is tried again for as long as it lasts and never ends an install. GET /apps/{id}/installs shows each install's settle_failures and settle_next_at, GET /apps/report counts pending_failing beside pending, Analyze › Mobile Apps warns when any are failing, and the cron job's summary line counts the installs it retired. The upgrade adds the two columns and an index to 202_app_installs.
  • Fixed: p202 campaign optimize (and the MCP tool campaign_optimize) dropped the error of its read of the other campaigns' figures, so a server error, a key whose scope refused it or an unreachable server read as a ranking with no campaigns in it: the command said there was no salvage offer and exited 0. It now fails with that error, its category, exit code and hint, and prints nothing else (under --json stdout is empty and the error envelope is on stderr); so does a summary or breakdown answer that is not one, which read as a campaign with no traffic or as no rows (p202 report breakeven, losers, winners and crosstab read the breakdown the same way, and now exit 4 naming the server where they exited 1 or ranked nothing). Each ranked row carries the campaign's aff_campaign_id, and the Next step names the first one in p202 rotator create --default-campaign, where it printed the placeholder salvage_id; it names one only when that campaign earned more per click than this one, and otherwise says there is no salvage offer and gives p202 report losers for the same window and filters. --aff-campaign-id, which narrowed the ranking to one campaign, is refused (the campaign is the command's argument), and a configured report.aff_campaign_id default no longer narrows it
  • Fixed: When the LTV maintenance job (202-cronjobs/ltv_maintenance.php) attached a customer to its company, the customer kept the updated_at of its last other change (GET /ltv/customers/{id}, p202 ltv customers ), so the change read as older than it was. The job now sets updated_at when it attaches a customer, as every other change to a customer's company does: a customer save, a company rename or merge, and the job storing a company of nothing but spaces as empty
  • Fixed: The container image (the Dockerfile the Coolify and production docker compose stacks build) installs its Composer dependencies from composer.lock, the versions the release zip ships. It copied composer.json alone, so every build resolved whatever versions matched it that day: two images of one release could run different dependencies, an upstream release could change an image with no commit, and no image could be rebuilt as it was (built on October 28, 2026 it would have taken newer symfony/console, symfony/string, composer/ca-bundle and three polyfills than the lock's). The build now stops when composer.lock is out of date with composer.json, where composer install only warned and installed the old set: run composer update and commit composer.lock. The container's own install at startup, when vendor/ is missing (the development stack), stops the same way, and stops when there is no composer.lock. Rebuilding an image you built before moves its dependencies to the locked versions
  • Changed: The rest of /ltv answers money, averages and rates as JSON numbers and counts and unix times as integers, as GET /ltv/summary and the customer reads do. GET /ltv/subscriptions (p202 ltv subscriptions): amount, mrr and customer_ltv. GET /ltv/breakdown by campaign, ppc_account and landing_page (p202 ltv breakdown): total_revenue, avg_ltv, aov, repeat_rate, mrr, spend, cac and ltv_cac, and total_orders as an integer; by product: total_revenue, avg_revenue_per_customer, aov, repeat_rate, mrr, units (a number such as 1.5) and orders (an integer). GET /ltv/products (p202 ltv products), and the product PATCH /ltv/products/{id} and its delete's dry_run answer: price, or null for a product with none. GET /ltv/companies (p202 ltv companies): total_revenue and mrr, and order_count and last_activity_time as integers. GET /ltv/abm and /ltv/abm/company (p202 ltv abm): total_revenue, mrr and the avg_time_on_page, avg_scroll_depth and avg_video_pct averages, and the company view's last_activity as an integer. Each answered MySQL's text ("29.99000", "44.562500000", a count of "4", a time of "1793183200"), so a subscription's amount was a number in GET /ltv/customers/{id} and text in GET /ltv/subscriptions. A name, a sku, a reference or a product key stays a string, digits or not. docs/openapi.yaml now types every /ltv read. A client that compared these fields as strings now gets numbers. p202's tables show such an amount to the cent (9.50 where they showed 9.5) and a zero rate as 0 (it showed 0.0000), and the PHP CLI's tables 29.99 where they showed 29.99000
  • Fixed: A database dropped and installed again under the same name (a reinstall, a wipe and a new install, a test loop) no longer reads the previous install's REST API state as its own. Idempotency records, staged changes, sync jobs and rate limits live in a directory in the server's temp dir, which was named by the database's host and name alone, so the new install took the old one's: a create sent with an Idempotency-Key the old install had answered was answered 201 with the old record and nothing was created (the agent-eval seed, run again after a reinstall, created none of its records), and the old install's staged changes were listed to the new install's users and could be applied to its database (an old staged DELETE of campaign 1 deleted the new install's campaign 1). The directory is now named by the install as well: a random install id the installer keeps in 202_deployment_secrets, which the upgrade to 1.9.79 gives an existing install (as does its first API request, when the files are deployed before the upgrade runs). A store that cannot read the id refuses (the API answers 500, or 503 while the upgrade is pending) and never falls back to the old name. The directory used before 1.9.79, and any earlier install's, are left where they are and never read; the server log names each once ("p202: the API state directory ... is not used: ..."): if it holds this install's own staged changes or sync jobs from before the upgrade, copy what it holds into the directory the log names, otherwise remove it. P202_SERVER_STATE_DIR, when it is set, now holds a directory per install as well (the next line)
  • Fixed: One identity merge that could not be re-queued stopped multi-touch attribution for the whole install. The attribution worker takes the merges (two visitors found to be one person) first in each run, oldest first, and a merge whose re-queue failed ended the run with an error, so on every later run the same merge failed first and no other merge's conversions were credited again, and the conversion outbox, model changes and the report rollup did not run either (the cron log said "Attribution worker failed" every minute). Measured: a merged person with more visitor ids than one database statement can name (65,533) failed every run with "too many placeholders". Each merge now takes its turn: a merge that fails is recorded on its row and passed, so the merges after it and the rest of the run still run, and it is tried again on the next run, then after a minute, doubling to an hour. Only a failure the merge's own data causes ends its retries: that person is recorded as retired, with the reason, and not tried again until 202-cronjobs/attribution-worker.php --retry-now, which now makes failed and retired merges due again too. A lock wait or deadlock with a conversion being recorded, a lost connection or a missing table is retried for as long as it lasts and never retires a merge. GET /attribution/queue (p202 attribution queue) counts merges_failing and merges_retired and lists them with their last_error in merge_rows; merges_awaiting_requeue no longer counts a retired merge. The Attribution page warns when any merge is failing or retired, and the worker's summary line says how many failed or were retired. The upgrade adds four columns and an index to 202_identity_merges.
  • Fixed: Product analytics (the events and attributes the pages record for the Tracking202 portal) are delivered one run at a time. 202-cronjobs/sync-messaging.php, which an operator may schedule for a tighter cadence than the cron job's hourly delivery, kept its runs apart by a file it made and deleted (messaging-sync.lock): a run still delivering ten minutes later had a second one started beside it, a run that died kept every run for the next ten minutes from delivering, and nothing kept it apart from the hourly delivery of the cron job (202-cronjobs/index.php). Two deliveries at once read the same waiting events, so each was sent twice and, while the portal could not be reached, spent two of its five attempts on one outage. Both now hold one lock for the whole delivery (flock() on 202-cronjobs/analytics-delivery.lock), which has no age and goes with the run's process if it dies. While one holds it the other delivers nothing: sync-messaging.php says "Analytics delivery already running", exits 0 and writes "Analytics delivery already running: another run holds …" to the server's error log, and the cron job's hourly run skips its delivery and goes on. The web server's user and the user a crontab's php line runs as must both be able to open that file, as for cron-run.lock: a run that cannot make, open or lock it delivers nothing and says why (sync-messaging.php exits 1 with "Analytics delivery failed: …"; the cron job's hourly run writes the same to the error log and goes on with its other work). So an install that made an empty cron-run.lock by hand, because its web server cannot write 202-cronjobs/, needs an empty analytics-delivery.lock there too, readable by every user. A messaging-sync.lock an earlier version left in 202-cronjobs/ is no longer read and can be deleted
  • Fixed: A database installed again under one P202_SERVER_STATE_DIR (the Coolify stack's api_state volume, which keeps the API's state across a reset of the db_data volume) no longer reads the previous install's REST API state as its own. The directory was used as named, for whichever install was there, so the bug the line above fixes for the temp directory stayed wherever the directory was set: a create sent with an Idempotency-Key the old install had answered was answered 201 with nothing created, and the old install's staged changes were listed and could be applied. Each install now keeps its state in a directory of its own inside it, install-<hash of the install's id>, made (mode 0700) by the first request that needs it and used only when it is a real directory the server's user owns and its group and others cannot write; a process running as root does not make it in a directory another user owns. When the install's id cannot be read the API answers 500 (503 while the upgrade is pending) and never falls back to the directory itself. State an earlier version wrote at the directory's top level (idempotency/, staged_changes/, jobs/ and the rest) and another install's directory are left where they are and never read; the server log names them once ("p202: the API state in ... is not used: ..."): after an upgrade, copy this install's own staged changes or sync jobs from the top level into the directory the log names, then remove what is there. A database restored from a backup keeps its install id, and so its directory, which still holds what the API recorded after the backup: empty it before the first API request after a restore
  • New: Account › Settings tells an admin about the traffic sources in another user's account, for owners who use neither the API nor the CLI. When GET /system/traffic-source-owners lists anything, an Admin or the Super user whose role has access_to_settings (the routes' own checks, asked of the signed-in user, who must still be switched on) sees a notice above the tiles saying how many sources are listed and how many need a user chosen. Its Review link opens a panel that lists each source as the report does: the user it is in, each user whose traffic source accounts name it with those accounts, clicks and conversions, and the user it goes back to or why you choose. Each row's Move back… opens a confirmation that names the source, the user it leaves and the user it goes to, or asks which of the users whose accounts name it; the move is POST /system/traffic-source-owners/repair's own code, sent the owner and target the panel showed, so it takes the same locks, offers no user the viewer may not act on, and refuses, in the panel the page lands on, a source that changed after the page was read. The form carries the session's token; the notice reads only whether anything is listed, and the clicks are counted when the panel is open
  • Fixed: The answers PayKickstart's, JVZoo's and ClickBank's notification URLs (tracking202/static/paykickstart.php, jvzoo.php and cb202.php) give a notification that names no click (200 no_click) and one whose click is not a click of the account whose key verified it (404 unknown_click) were worded in the shared processor for PayKickstart: no_click named the checkout link's subid, and unknown_click named no field at all and said the key "signed" the notification, which a ClickBank notification is not (it is encrypted). JVZoo and ClickBank each answered no_click in words of their own to get around it. Each provider now says where its click comes from, and the processor words both answers from that, once: PayKickstart's custom_subid (subid=[[subid]] on the checkout link), JVZoo's caffitid on the Affiliate IPN URL (?tid=[[subid]] on the affiliate link) or the subid in cvendthru on the vendor's JVZIPN URL (?subid=[[subid]] on the buy link), and ClickBank's tracking code or extclid (tid=[[subid]] or extclid=[[subid]] on the HopLink). Every answer keeps its status and outcome but one: a ClickBank notification that names no click and carries a transactionTime that cannot be read is a 400 naming transactionTime, as PayKickstart's and JVZoo's already were, rather than a 200 no_click. A JVZoo or ClickBank no_click is logged with its type and receipt, as PayKickstart's was. A JVZoo affiliate's tid that is there and is not a click id is still answered 200 no_click showing the value, since the tid is the affiliate's to use for other trackers
  • Fixed: p202 diff stops on a profile URL the server will not call, as p202 sync does. Against an install that computes diffs itself (sync_plan), POST /sync/plan answers such a URL with a 422 on source.url or target.url, and p202 diff took any failed plan as "no server-side diff" and compared the two profiles from your machine instead: the refusal was never shown, and your machine made the very calls the server had refused. It now exits 1 (validation) with the server's message and p202 sync's hint, which names the profile to repoint (p202 --profile config set-url https://…) and P202_WEBHOOK_ALLOW_NETWORKS, the setting that admits an install on that server's own machine or network, and reads nothing from either profile. A server without the plan route, or one that refuses something else (an entity it does not know), still leaves the diff to your machine, as before
  • New: A traffic source that traffic source accounts of more than one user name, which no move back clears (wherever it is, another user's accounts still name it, so it was listed again and Account › Settings' notice never went away), can be split. POST /system/traffic-source-owners/split (p202 system traffic-source-owners split , and Split… on the Settings panel for a source marked owner_has_accounts or several_users) leaves the source in the account it is in, untouched, and gives each other user whose accounts name it a new source in their own account with its name, its live custom variables and its removed mark; that user's traffic source accounts (removed ones too) move onto their copy, a report filter of theirs set to the source is set to the copy, and those accounts' clicks are queued for the report rollup, so the clicks already recorded on them are reported under the copy after the cron job's next run. The custom variable values recorded on those clicks before the split stay recorded against the source's own variables. The split is one transaction that locks the users, the accounts that move and then the source, reads everything again and writes nothing unless it still resolves the same way, so a failure part way leaves nothing written; it is logged. It takes back the owner and the users the report showed (owner_user_id, copy_to_user_ids; the CLI and the panel always send them) and refuses a source that changed since, copies nothing for a deleted user or one with no record, and needs an Admin with access_to_settings who may act on the user the source is in and on each user who gets a copy. To have the source end up with another user, move it there first, then split it. GET /system/traffic-source-owners also says how many custom variables each source has (custom_variables)
  • Fixed: Security. A user switched off in My Account › Users, or removed, kept every page, AJAX request and form of a browser already signed in, for as long as they kept using it: the sign-in, the remember-me cookie and the API keys refused them, but a session already signed in was never asked again, and each request kept it alive. Every signed-in request now reads whether its user may still act, in the read that already loads the user's role (no extra query): when the user is switched off, removed or no longer there, the session ends on that request as a sign-out ends it (a new session id, the remember-me cookie cleared) and the request is answered as signed out, with nothing it asked for done. Every remember-me key the user holds is revoked then, so a browser kept signed in does not sign back in when the user is switched on again (switching the user off now revokes them too, below). A user switched back on signs in again as usual. The sign-in, the remember-me cookie and the session now ask one rule: active is user_active 1 and not removed is user_deleted 0, so a value no version writes is a user who may not act (the sign-in let a user_deleted of 2 through). A read of the user that fails is the database error page, never read as signed in or as signed out. A request a page's script makes (jQuery's, or fetch()) that is not signed in, for this reason or because its session expired, is answered 401 "You are signed out. Sign in again, then reload the page.", which the page shows as a failed request; it was sent to the sign-in page, and the page drew the sign-in page where the answer belonged
  • Fixed: Security. A remember-me cookie ("Keep me signed in") went on signing in after what should have ended it. Signing out cleared the cookie in that browser and kept the stored key it names, so a copy of the cookie (a copied or synced browser profile, a backup of one) signed in for the rest of its 14 days. Signing out now deletes that browser's key, and so does the sign-out Personal Settings makes after too many wrong current passwords. It deletes that browser's key only: the same user's other remembered browsers are sign-ins of their own and stay signed in (switching the user off ends them all). If the key cannot be deleted, the browser is still signed out, its session ended and its cookie cleared, and the sign-out ends on the database error page, which records the error; the key then stays valid until it expires or the user is switched off. Switching a user off (My Account › Users, PUT /users/{id}, p202 user update --user-active 0) kept every remember-me key of theirs, so a remembered browser that made no request while the user was off signed back in once they were switched on again. Switching off now deletes every key the user holds, in the same transaction as the switch-off: a switch-off whose keys cannot be deleted is not saved (the page says nothing about the user changed, the API answers 500). Removing a user already deleted their keys
  • Fixed: A sync (p202 sync and re-sync, the sync jobs) writes a redirector's rules on the target as one set (PUT /rotators/{id}/rules), which the target takes whole or refuses with nothing written. It removed the target redirector's rules and then posted the source's one at a time, so a rule the target refused stopped it with the target holding only the rules before that one: on a re-sync the target's own rules were gone. Since this version refuses a rule with no criterion, a source rule stored without one, which had synced, did exactly that. The refusal names the source rule (give it a criterion; the value ALL keeps it on every visitor). A target from before 1.9.79, which has no rule set route, is written to one rule at a time as before
  • Fixed: Security. A new password left every remember-me cookie ("Keep me signed in") of the user signing in, so a stolen cookie went on working after its owner changed the password to lock the thief out. Changing your password on Personal settings, a reset through the lost-password link, an admin setting a password on My Account › Users, and PUT /users/{id} with user_pass (p202 user update --set-password) now delete every remember-me key of the user in the same transaction as the password: each browser kept signed in, and any copy of its cookie, has to sign in with the new password. On Personal settings the browser making the change stays signed in; its own key goes with the rest, as a stolen cookie is a copy of one, and its cookie is cleared, so it asks for the new password at its next sign-in. A new password whose keys cannot be deleted is not saved: the old one still works, and Personal settings, the reset page, Account › Users and the API (500) say the change failed. A sign-in that stores the same password again under a newer hash (one an older version stored as MD5) is not a new password and keeps every remembered browser. A browser already signed in is signed out at its next request too (see below)
  • Fixed: The first page a browser opened with only its remember-me cookie ("Keep me signed in", after the browser was closed) ran without the user's roles: the page signed the browser in after the app had read the user, so the app had read nobody. A page that asks for a permission refused it (My Account › Users sent even the Super user to the account page), and the menu left out what the user's role gives. The cookie now signs the browser in before the user is read, so that first page has the user's roles, as every page after it does. A browser holding a remember-me cookie and no session is now signed in by it on any page, not only on one that asks for a sign-in; a signed-in browser reads nothing more for carrying the cookie
  • Fixed: Setup › Traffic Sources saved an account edit on whatever account the link's id began with: a save from ?edit_ppc_account_id=12abc (a link edited by hand or built by a script) changed account 12, one of the account's own and not the one named, and took its stored postbacks as the edited account's. The id is read exactly now; anything else is refused with "Pick the traffic source account to edit from the list." and nothing is changed
  • Fixed: Setup › Redirector showed a rule with more than one destination and the Split test switch off with only its first destination, and saving the redirector removed the others, with nothing said, though the redirects split the rule's visitors between all of them by weight (the switch is the page's: neither redirect link reads it). The API makes such rules: a rule sent with two redirects and no splittest. The page now shows a rule with more than one destination as a split test, each destination with its weight, and saves it as one; a weight of 0, which the page does not take, has to be raised or the destination removed before the redirector can be saved there
  • Fixed: p202 report losers and report winners (and the MCP tools report_losers and report_winners) with --aff-campaign-id dropped the error of their read of the campaign's payout, so a server error, a key or role the server refused, a campaign that does not exist or an unreachable server priced every click at a payout of 0: losers listed only the rows that never converted, leaving out every row that sells below its break-even, winners listed rows whose cost per click is past it, and both exited 0. A failed read now fails the command with that error's category, exit code and hint before any row is read, and prints nothing else (under --json stdout is empty and the error envelope is on stderr); a campaign that does not exist names p202 campaign list. An answer with no aff_campaign_payout in it (a proxy's page, a record without the field), which every reader of the payout read as 0 (p202 report breakeven and campaign optimize too), is a server error (exit 4) naming p202 campaign get --json, as is an answer that is not JSON, which exited 1 with a hint to read --help. A campaign whose payout is 0 is still priced at 0, and a payout sent as a number (1.9.77 on) or as a numeric string (before) reads the same. When the rows a command prints could not be encoded as JSON (a figure the server sent too large to compute with), report breakeven, losers and winners, and the eight other commands that print rows the same way (rotator test, check, trace and criteria-values, tracker test and check, conversion simulate, and system cron's table), printed an error on stderr and nothing on stdout, and exited 0; they now fail with exit 4 and a hint, having printed nothing
  • Fixed: Security. A new password ended the remember-me cookies but left every browser already signed in as the user signed in, so whoever had used a stolen cookie, or was signed in on a shared computer, kept that session after the owner changed or reset the password. Changing the password on Personal settings, a reset through the lost-password link, an admin setting it on My Account › Users, and PUT /users/{id} with user_pass (p202 user update --set-password) now sign out every browser signed in as the user at its next request, as a sign-out does; the browser that changed its own password on Personal settings stays signed in, and an admin who sets another user's password stays signed in too. Switching a user off, and removing one, do the same, so a browser that made no request while the user was switched off no longer goes on once they are switched on again. A sign-in that stores the same password again under a newer hash signs nobody out. Browsers signed in before the upgrade to 1.9.79 stay signed in through it. The upgrade adds a column to 202_users (user_session_generation, counted up by each of these changes); until it has run, nothing is compared and every signed-in browser goes on as before. A change whose sessions cannot be ended is not saved: the old password still works, and the page or the API (500) says the change failed
  • Fixed: A sync (p202 sync, POST /sync/jobs) keeps a redirector rule that is switched off as switched off on the target. A rule an older install stored with no status, which the redirects skip, reached the target as an active rule and started taking that redirector's traffic there
  • Fixed: Slack notices (the Slack incoming webhook on Account › API integrations, or user_slack_incoming_webhook through PUT /users/{id}/preferences and p202 user prefs update) are sent only to public https:// addresses, by the export webhooks' guard and sender. The page saved any webhook URL, http:// and internal hosts included, while the API asked only for https://, and each notice was posted with no address check and no time limit: a webhook aimed at this server's own network was called on every notice, a failed sign-in among them, and one whose host never answered held the request that sent it, so on a server without PHP-FPM ClickBank's test (which waits 3 seconds) failed while the page showed the key Verified. The page and the API now save the same webhook URLs, and refuse, saying why, plain http, a private, loopback, link-local, carrier-grade NAT, metadata or other non-public address, a local name such as localhost, a user name before the host and a fragment. Each notice is checked again when it is sent (every address the host resolves to), goes only to the address that passed, with no proxy and no redirects, and gives up 2 seconds after its address is resolved (the lookup itself is bounded only by the server's resolver). A notice that is refused or not taken is not retried: the server's error log names the notice and why, never the webhook's path, and the sign-in, change or ClickBank test that sent it answers as before. A webhook saved by an earlier version that is refused now gets no notices until it is saved again: save it as the https://hooks.slack.com/... URL Slack shows for the webhook. P202_WEBHOOK_ALLOW_NETWORKS in 202-config.php, the export webhooks' setting, admits a receiver on the operator's own network; link-local and metadata addresses are never admitted
  • Changed: Personal Settings no longer has a Daily email report setting, and the install no longer calls Tracking202's hosted daily email service, which Tracking202 switched off on 29 October 2026. Saving the profile, the installer and the 1.9.6 upgrade step registered the owner's hour with it, and 202-cronjobs/daily-email.php sent it the owner's email address and the day's campaign figures when it asked; daily-email.php now answers 410 and reads nothing. The user_daily_email preference is still accepted by PUT /users/{id}/preferences and p202 user prefs update --user-daily-email, and stored, so existing scripts are not refused, but it does nothing
  • Changed: The licence check allows a 30-day grace period when Tracking202 (my.tracking202.com) cannot be reached: the install counts as licensed only if the last answer for its ClickServer API key was valid and came within the last 30 days (for the Go CLI and connecting an AI assistant, a paid Pro licence within the last 30 days). The install keeps that answer in its database (202_licence_answers, which the upgrade to 1.9.79 adds), under the key's SHA-256 and never the key itself, so a restart or a redeploy does not lose it. The upgrade to 1.9.79 copies into it the answers the install already holds on disk from the last 30 days, and a key with no answer in the database yet, or whose answer the install cannot read from it, uses its answer on disk from the last 30 days. With no such answer (never checked, last answered invalid, answered longer ago, or an upgraded install with no answer on disk, for example after a container was replaced) the licence is not confirmed: every page of the web app that needs the licence is locked behind a page saying the licence could not be checked with Tracking202, and the Go CLI and AI connections are refused (402) with the same words; each asks you to make sure the server can reach my.tracking202.com over HTTPS and names support (support@tracking202.com or the support chat). Everything opens again once Tracking202 can be reached and confirms the licence. GET /capabilities reports shell and cli by the same rule. An answer from Tracking202 still decides at once, a refusal included
  • Fixed: A redirector rule on the visitor's ISP or carrier sends a visitor the same way from a redirector link and from a landing page's offer link. The offer link compared the whole ISP name, so for an ISP whose name has a comma ("Comcast Cable Communications, LLC") an "is" rule never matched there and an "is not" rule always did
  • New: You choose how clicks are filtered (Personal Settings › Click filtering; GET and PUT /users/{id}/click-filter; p202 click-filter). Each of the three checks that mark a click filtered can be switched off: a click from an address a user of the install last signed in from, a click from the built-in search engine crawler ranges, and a repeat click from one address, whose window is 1 hour, 6 hours, 24 hours (the default, as before) or 7 days. An address list of up to 500 IPv4 or IPv6 addresses or CIDR blocks marks each one always counted (a client's office that tests your links; it wins over every check) or always filtered (a click farm; it wins over the three checks), compared with the address a click arrives from, before privacy masks it. Test an address on the page, with GET /users/{id}/click-filter/test or with p202 click-filter test, to see whether a click from it would be filtered and by which rule. An account that changes nothing is filtered exactly as before, and bot detection always applies
  • New: A filtered click says why: GET /clicks/{id} and GET /clicks answer filter_reason (sign_in_address, crawler_range, repeat_visit, filter_list with the list's filter_entry_id, or bot), as p202 click get and click list do, and GET /clicks takes filter_reason to list the clicks one rule filtered (p202 click list --filter-reason; not_recorded lists the filtered clicks that have no reason). A click filtered before 1.9.79 has no recorded reason and answers null, except a bot click, which answers bot
  • New: An AI assistant connected through p202 mcp reads and changes the click filter (click_filter_get, click_filter_update, click_filter_test, click_filter_list_add, click_filter_list_remove) and reads the account's settings (settings_get: GET /users/{id}/preferences' answer, a secret only as whether one is stored). Tracking202's hosted MCP server does not offer them, since its connections do not reach account settings; click_get answers filter_reason on both
  • Fixed: Syncing a Smart Redirector whose rules include one saved before 1.9.79 with no criterion (which sends every visitor) now copies that rule as "country is ALL", which sends the same visitors, instead of stopping the redirector's sync. A redirector with more rules than the target accepts in one write (200) now fails its sync saying how many rules it has, and the target keeps its rules unchanged.
  • Changed: The hosted MCP engine prints a read answer without first building a map for every JSON value, and reads a timeseries answer's last bucket without decoding the whole answer twice, so a large answer uses far less memory and CPU per call.
  • Changed: Building the Go CLI from source now needs Go 1.27 (it needed 1.25).
  • Changed: Building the Go CLI from source now needs Go 1.27.3, which carries Go's latest security fixes (crypto/x509, crypto/tls, net/http and others); release binaries are built with it.
  • Changed: Customer LTV now asks for a role permission, on the pages and in the API alike. Viewing it (Analyze › Customer LTV, the customer, company, product and subscription pages, the CSV download, GET /api/v3/ltv/*) needs Access to campaign data, as the reports' money does, and so does sending revenue, events, subscriptions and next-offer impressions through the API, so existing integrations keep working; changing customers, companies and products needs Access to the Setup section; and LTV Settings (its page; in the API, webhooks and integrations read or changed, and custom fields changed) needs Access to API integrations, as Account › API Integrations does. It asked for none, so a user with the Publisher or Campaign viewer role, which holds no permissions, saw every customer's details and revenue and could change LTV Settings. An API key keeps needing its ltv:read or ltv:write scope as well