Skip to content

v1.9.77

Choose a tag to compare

@github-actions github-actions released this 13 Oct 06:17
· 737 commits to master since this release
47e440b

Update now: this release includes a security fix

Update every Prosper202 install to 1.9.77 as soon as you can. Then sign in and open Account › User Management: if you see a Super-user (or any other user) you don't recognise, remove it. Updating stops new accounts being created this way, but it does not remove one that already exists.

⚠️ 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.77 removes the old chat's local tables (202_messaging_conversations, 202_messaging_messages) as part of the upgrade.

Check one setting before you upgrade. In Account › Settings › Click data, look at "Delete click data older than … days". Before 1.9.77 that setting deleted almost nothing; 1.9.77 makes it work, so from the first cron run after the upgrade it deletes every click older than that many days, a batch at a time. If you want to keep that history, set it to 0 (keeps every click) or a larger number first.

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

The Go CLI needs a ClickServer Pro licence

From 1.9.77 the Go CLI (p202) needs a Prosper202 ClickServer Pro subscription or trial. Without one, its commands stop with a message linking the trial (error category licence, exit code 6). The web app, the PHP CLI (bin/p202) and direct API calls keep working with the same API key. If my.tracking202.com cannot be reached, the CLI keeps working. p202 config test still reports a successful connection on an install that refuses the CLI.

Download

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

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

What's new and what changed

New

  • Chat with the Tracking202 team has a new chat box. Replies arrive as they are sent, the box shows when the team has read your message or is typing, and announcements are listed beside your conversation with their subject.
  • Web addresses in chat messages, the team's and your own, are now links that open in a new tab.
  • Prosper202 no longer keeps chat in its own database: your conversations and announcements are held by the chat service. An install without a customer API key shows no chat box.
  • The chat box's connection request also tells Tracking202 which Prosper202 version your install runs, so we can see who still needs an update. It travels only with the request the chat box already makes, so an install without a customer API key sends nothing.
  • Overview › Rotator Breakdown now lists a deleted rule that still has clicks, and Analyze › Custom Variables marks a variable you removed as "(removed)".

Improved

  • API keys now follow their user's role: a key can do only what that user may do on the pages, and the keys of a user switched off in Account › User Management stop working. The Setup pages' save and delete actions also check the role's Setup permissions.
  • Moving a traffic source account to another traffic source, or a campaign to another category, now updates the stored report data for their past clicks. An hour of report data that fails to build is no longer marked as done, so it is tried again.
  • When a report cannot be read, the report pages now say so instead of showing zero clicks or "No clicks in this range".
  • Update › Upload Revenue Reports now lists the first 1,000 lines it did not record and counts every skipped line by reason, instead of listing them all.
  • Password reset emails now link to the install owner's tracking domain. With no tracking domain stored, the email names the page and gives the key without a link.
  • The dashboard stays open when Tracking202's sign-in licence check answers with an error page or something unreadable, instead of locking.
  • Slack notices sent by the cron job now carry a sender name (your tracking domain, or Prosper202), and a scheduled click deletion sends its notice once, when it finishes, rather than on every cron run.
  • The upgrade and requirements pages load faster on a server with many databases, because they now look only at Prosper202's own database.
  • For developers and API users: the REST API and the p202 CLI are stricter (they refuse a bad or unknown field instead of skipping it), and they now cover every page of the web UI.

Fixed

  • "Delete click data older than N days" in Account › Settings now deletes the clicks it promises; it deleted almost nothing and could also remove a newer click. If the setting is on, deletion starts on the first cron run after you upgrade and works through the backlog in batches, keeping conversions, attribution, LTV and app records.
  • A scheduled deletion of click data from before a chosen day now goes by that day, in your account's time zone, and refuses a date that does not exist or is later than today. Both deletion settings now take effect when an Admin other than the install owner saves them; before, those changes were stored where the cron job never looked.
  • Real visitors are no longer recorded as bots because an earlier click with the same browser came from a listed bot range (Google, Microsoft, Facebook, Apple); they had dropped out of Real clicks and out of attribution's clicks and cost. Redirector links now check each click against those bot ranges too, as every other link does.
  • Advanced landing page offer links no longer show an error to visitors whose browser does not send back the click cookie, for example with third-party cookies blocked. With cloaking turned off for the link, they go straight to the offer even when the campaign uses cloaking.
  • Landing-page links copied from Get Links now record clicks; the keyword was run into the tracker id, so the link recorded nothing. With no tracking domain stored, links and code from Get Links, Get LP Code and Postback/Pixel now use the address you opened the site on, rather than the address the web server listens on, which nobody can reach behind a reverse proxy or container.
  • On an install served on a non-standard port (such as 8080), browsers now keep the click cookies, and on such a port, behind an HTTPS proxy or from a subdirectory, cloaked redirects no longer send visitors to an address that refuses the connection. A pixel with only the visitor's address to go on now finds the right click behind a CDN or load balancer and for IPv6 visitors, and a pixel after an advanced landing page now credits the click in the campaign the visitor came through.
  • The privacy setting in Personal Settings now does what it says. "Enabled for European Traffic" had no effect and now covers visitors placed in Europe or not placed at all (every visitor when no GeoIP database is installed); under privacy every stored visitor address is masked, and the stricter of the install owner's and the link owner's setting applies.
  • On an install with more than one account, Visitors, its download, Spy and the Overview chart show only the signed-in account's clicks, and a record linked to another account's campaign, category or traffic source no longer takes that account's names, payouts or settings. The daily email no longer fails on days with clicks, and it covers the install owner's own campaigns.
  • Report pages use your account's current time zone straight away (they kept the zone from your last sign-in), named ranges such as yesterday and the last 7 days stay whole days across clock changes, and Day Parting, Week Parting, Breakdown Analysis and the Overview chart place each click at its own local time, including in half-hour zones such as India. Saving Personal Settings no longer changes an older zone name such as Europe/Kiev to Africa/Abidjan.
  • The keyword, referer and IP filters now give the same answer on Analyze, Visitors and the Group Overview: an IP filter matches exactly that address, % and _ are matched as typed, and referer counts are no longer cut short or multiplied. Visitors and its download show an IPv6 visitor's address instead of a number.
  • Overview › Rotator Breakdown counts each rule's own clicks and includes clicks sent through a landing page's offer rotator, and the Overview tables now list advanced landing page clicks by traffic source. Group Overview gives a c1–c4 value of "0" its own row and no longer shows an error for Redirector with Rule, and report pages show a negative amount as ($5.00) instead of ($-5.00).
  • Redirectors send visitors where their rules say: a landing page's offer rotator now splits clicks by weight (it sent every matching click to the rule's first redirect), and URL or landing-page rules made through the API or CLI now redirect instead of sending visitors to an empty address. An IP address rule now compares addresses as addresses, so letter case, written-out zeros or a space after a comma no longer stop a match, and Setup › Redirector refuses a range or a typo and says why.
  • Setup › Traffic Sources can now remove an account's last pixel (clearing its code left it firing), and a Raw pixel keeps its backslashes when the account is saved. A custom variable you remove no longer appears on Get Links' links or on new clicks, and one removed and added back is no longer counted twice in Analyze › Custom Variables.
  • Update › Upload Revenue Reports now picks the commission column correctly when the report also has columns such as Sale Amount or Sale ID, reads a very long line whole, and skips a commission written like 12,50 with a reason instead of recording 1250. Update › Reset Campaign Subids counts a click that was visited again only once.
  • The installer no longer opens on a site that is already installed, and a fresh install gets past the licence-key step again.
Full changelog (for developers)
  • New: The Setup section's code and per-source settings have an API and CLI commands, so an agent can finish a landing-page tracker or a conversion setup without the web UI. GET /landing-pages/{id}/code (p202 landing-page code <id>) answers Get LP Code's loader script and outbound links, for a simple page or for each of an advanced page's offers (?offers=campaign:12,rotator:3, --offer), byte for byte the page's (both build them with Prosper202\Setup\LandingPageCode, on the caller's own tracking domain as the pages read the signed-in user's, or with none stored on the host the request came in on). GET /conversions/postback-code (p202 conversion postback-url, extended, and p202 conversion pixel) answers Postback / Pixel's simple, advanced and universal pixels and postback URLs for an amount, sub id macro and campaign. /ppc-networks/{id}/variables (p202 ppc-network variable list|create|update|delete) edits a traffic source's custom variables, which the tracking link carries, and /ppc-accounts/{id}/pixels (p202 ppc-account pixel ...) an account's pixels and a Postback's correction URL. Each asks for the Setup pages' role permission (access_to_setup_section; the variables, remove_traffic_source as well, as the page's variables dialog does), reaches only the account's own records, and refuses what the pages refuse, plus what would break a link or a snippet; the writes can be staged and the deletes previewed
  • New: The UI's Update section has an API and CLI commands. POST /clicks/cpc sets what past clicks cost (p202 click update-cpc): a dry run counts the clicks and names the highest click id, and the write runs only if those clicks still number the same, so a click recorded after the count is never changed. POST /conversions/subids marks subids converted (p202 conversion mark-subids), /conversions/subids/delete clears them (delete-subids), /conversions/subids/reset clears a category's or campaign's (reset-subids), and /conversions/uploads records a revenue report as a batch whose values replace earlier uploads' (upload-revenue, columns read from the header or named). Each runs the page's own code, asks for the role permission the page asks for (access_to_update_section, and delete_individual_subids to delete subids), takes ?dry_run=1, and says how much stands when it stops part-way. A subid list is answered line by line; an upload answers as the Upload Revenue Reports page lists it: how many lines were recorded, on how many clicks and for what total, each click's sum, the header and each line it skipped with the reason, and every skipped line counted by reason. The sums and the lines are listed for the first 1,000 of each, with counts of the rest, so a report of the 8 MB the route takes answers in kilobytes, and a preview reads the report twice rather than holding it, within PHP's default 128 MB. update-cpc, delete-subids, reset-subids and upload-revenue check first and ask before writing (--force skips the question, never the check); mark-subids, like its page, asks nothing (--dry-run previews); all five refuse --staged
  • New: Account › Settings and the integration URLs have an API and CLI commands: GET /system/info (p202 system info: code and database versions and whether the database needs an upgrade, PHP and MySQL versions, memcache, the PHP limits, clicks recorded, database size, when cron last ran, DataEngine progress), GET /system/login-log (system login-log --limit: who signed in, when, from where and whether it passed, never the password), GET and PUT /system/retention (system retention show|set --days N, 0 to 36500; set asks when it would keep less), POST /system/retention/delete-before (system retention delete-before --date, which always previews first: the preview counts, per click table, what the cron job will delete, and the write carries the preview's cutoff_time and is a 409 when the day now begins at another time), GET and PUT /system/isp-lookup (system isp-lookup show|enable|disable, on only while a MaxMind ISP database is in place) and GET /system/integrations (system integrations: the ClickBank, JVZoo, Zaxaa, Slack and PayKickstart notification URLs and whether each secret is stored, never the secret). Each needs Admin and the page's own permission (access_to_settings; access_to_api_integrations for the URLs), none can be staged, and every command refuses --staged before any request. p202 system metrics reads GET /system/metrics
  • New: p202 ltv does every LTV write the API offers, where it could only read: ltv customer upsert|update|merge|erase|alias add|alias remove, ltv company create|update|merge|delete, ltv revenue record, ltv engagement-event record, ltv subscription upsert|event, ltv product upsert|update|delete, ltv next-offer impression, and ltv fields, ltv webhooks (with ltv webhooks deliveries <id>) and ltv integrations. Reads take what the API filters by (ltv customers --search --segment --all, --cf key=value on summary, customers, breakdown and predict, ltv cohorts --months, ltv products). Every LTV write refuses --staged before sending (the server stages none), every delete takes --force, --dry-run and --ids, and erase says that personal data goes and revenue stays. The API gains PATCH /ltv/products/{id}, DELETE /ltv/products/{id} (a 409 while order line items name the product), GET /ltv/webhooks/{id}/deliveries (status, attempts, next retry and the last response or error; never the secret or the payload), and a ?dry_run=1 preview on every LTV delete: an erasure lists per table what goes and what is kept, a company delete the refusal it would meet, a field delete how many values go with it, a webhook delete its delivery queue
  • New: p202 conversion create sends everything POST /conversions takes: --conv-time, --status reversed with --reversal-id, --customer-id, --customer-ref, --customer-ref-type, --customer-crm and line items (--item, --items-file). Items go to the server, which decides whether a customer is linked (named, already on the click, or from the account's customer c-param); when it refuses for want of one, the hint names --customer-ref and --customer-id
  • New: GET /reports/* reaches what Analyze and the Group Overview show. Breakdowns add ip (IPv4 and IPv6), referer (the domain), referer_url, device_type, c1 to c4, utm_source, utm_medium, utm_campaign, utm_term, utm_content, rotator and rotator_rule; every report takes text_ad_id, region_id, isp_id, browser_id, platform_id, device_type, method_of_promotion (directlink, landingpage), show (all, real, filtered, filtered_bot, leads), keyword and referer (contains, with % and _ read literally), ip (exact, any IPv6 spelling) and ppc_network_id=none (or the pages' 16777215) for clicks with no traffic source; period (--period in the CLI) adds last14, thismonth, lastmonth, thisyear, lastyear and alltime, in the reports, LTV and attribution. GET /reports/groups?by=a,b,c,d (p202 report groups --by) is the Group Overview: up to four dimensions nested, every group the sum of its children, the clicks a level has no value for kept as its none child, and more than 5,000 innermost groups a 422. GET /rotators/{id}/stats (p202 rotator stats <id>) gives a redirector's totals, every rule (a deleted rule that has clicks included) and its default, adding up to the totals. The Go CLI's report commands take the same filters, dimensions and periods
  • New: GET /clicks and /clicks/{id} (p202 click list, click get) carry what Visitors shows, not only ids: the campaign, traffic source, network, landing page and text ad names (each only when the record is the click's own account's), the IP and keyword, the referrer, landing and outbound URLs, region, city, ISP, device name and type, and on a single click c1 to c4; values a visitor wrote are cleaned as names are. GET /clicks takes the Visitors filters: keyword and referer (contains), one IP, device type, text ad, location, browser, platform, traffic source and category, method of promotion, show and the named periods
  • New: p202 click list --follow, the Spy page: it prints the newest --limit clicks (default 10), then each new click once, oldest first, polling every --interval (default 5s, at least 1s) until interrupted or --stop-after elapses, when it reads once more; JSON is one object per line. It keeps its place by the server's click times, not this machine's clock, and re-reads the 15 seconds behind it, so a click written late still appears, once
  • New: GET /capabilities carries a principal block (the calling key's user id, roles and scopes, with no scope needed), and p202 whoami shows it with the username, email, profile and URL
  • New: A campaign's attribution model override (Setup › Campaigns) is the campaign field attribution_model_id in the API and p202 campaign update --attribution-model-id: null, 0 or "" is the account default, and another account's model, an unknown id or a value that only casts to one ('42.5', '042') is a 422 with nothing in the request written
  • New: Go CLI errors name the next command: a 404, 409 or spent-key hint names the failing command's own list command (p202 campaign get 9 answers "Run p202 campaign list") where one exists; with no server configured, the hint names p202 config set-url, set-key and config test (it pointed at --help); a 403 "Admin access required." names the Admin and Super user roles and p202 whoami; and each create's help has an example with every flag the server requires, with every id flag saying which list command its value comes from
  • New: documentation/cli/12-ui-parity.md maps every page of the web UI (Setup, Overview, Analyze, Visitors, Spy, Update, Account) to the REST route and p202 command that does it; documentation/api/20-ltv.md, 26-update.md and 27-setup.md document the LTV, Update and Setup routes
  • Changed: Chat with the Tracking202 team moved to a chat box that talks to the chat service directly, so replies arrive as they are sent (over a WebSocket, falling back to polling every 5 seconds while the page is visible and every minute while it is hidden), the box shows when the team has read your message and when someone is typing, and announcements are listed beside your conversation with their subject. Prosper202 no longer stores or relays chat: 202-account/ajax/chat-pass.php asks the portal for a signed pass with this install's customer API key and install hash (CHAT_PASS_URL, overridable from the environment; https only, http only on loopback) and hands the browser the pass and the chat service's address, never the key or the hash; the pass is kept in the session until an hour before it expires. An install with no customer API key shows no chat box, and a deactivated user gets no pass. The upgrade drops 202_messaging_conversations and 202_messaging_messages (the old chat's local copy) and removes inbox.php, thread.php, read.php, send.php, messenger.js and messenger.css; announcements and conversations live with the chat service. Product analytics are unchanged for pages: Prosper202Messenger('update' and 'trackEvent') post to track.php, which now delivers to the portal itself (throttled per user; the old chat box's poll used to do it) and answers when a held-back delivery is due. A loopback MESSAGING_API_URL no longer goes through the environment's http_proxy
  • Changed: The chat box's pass request also tells the portal which Prosper202 version this install runs, so we can see which versions are in use and who still needs an update. It is sent only with the request the chat box already makes (an install with no customer API key sends nothing), and nothing new reaches the browser
  • Changed: The Go CLI (p202) needs a Prosper202 ClickServer Pro subscription or trial. Without one, CLI commands stop with a message linking the trial (error category licence, exit code 6), and GET /capabilities reports the licence as cli (true/false). The install tells the Go CLI apart by its User-Agent, so this is a licensing prompt, not an access control: the same API key keeps answering the PHP CLI, curl and the web app. Only my.tracking202.com's own answer decides: an allow is remembered for an hour, a refusal for five minutes (so a new trial is served within five minutes); if it can't be reached or answers with anything else, the CLI keeps working, and the question is not asked again for a minute. The sign-in licence check (AUTH::is_valid_api_key) now also treats an error page or unreadable answer from my.tracking202.com as unknown and keeps the dashboard open, instead of locking it
  • Changed: The REST API refuses a request body's field instead of skipping it. Every write answers 422 naming the field for a key that is not a field of the resource (the error lists the writable ones), a read-only key (primary key, owner, version, etag) on a create, a null for a column that cannot be NULL, a whole-number field given anything else ("1.5", "1e3" and a 20-digit string were stored as 1, 1000 and PHP_INT_MAX), a number outside its column's range (every such refusal names the range) and a string longer than its column (a strict-mode 500 before); a null is written as NULL where the column takes one. A GET body sent back with PUT is accepted on every resource, SKAN encodings and rotator rules included: read-only keys are accepted with the record's own values, and a version or etag that differs is the 409 an If-Match gives. bulk-upsert rows meet the same rules with field_errors in each error row, an update with nothing left to write is reported skipped, and a bulk body that is an object with keys other than rows is refused. Every hand-written handler (Update, Settings, the Setup variables and pixels, goals, events, conversions, sync, rotators, users, LTV and the rest) refuses the keys it does not read. p202 import, sync and diff leave out the server-set keys (version, etag, landing_page_id_public, a rotator's auto_monetizer and rules); an export imported by an older CLI is refused for them
  • Changed: Objects and lists inside a request body are checked as strictly as its top level, and a refusal names its place (items.0.unit_pirce, customer_crm.country, aliases.0.tpye, events.1, prune_denylist.0, source.nmae). Each of these answered 2xx: on POST /conversions a line item's misspelt unit_pirce stored the line at 0, a unit_price of "abc" stored 0 and customer_crm's unknown keys were dropped; on POST /ltv/revenue an item's qty stored one unit and a country of "United States" was a 500; POST /ltv/customers stored a misspelt alias type as a custom alias, an alias or custom-field value given an object was stored as "Array", and on POST /ltv/events a customer_crm that is not an object read as none; "events": "revenue.recorded" (a string, not a list) subscribed a webhook to every event; an integration's config [1,2,3] was stored (it must be an object, and an empty one is served back as {}); a custom field's options kept a non-text choice as "Array" and were dropped from a text field; and on POST /sync/jobs a misspelt source key was dropped and "prune_denylist": "campaigns" protected nothing from a prune. Rotator rules refuse an unknown key inside a criterion or redirect (redirects.0.weigth stored the redirect at weight 100). A list entry is named field. everywhere: POST /events answers events.1.event_id where it answered events[1].event_id, POST /goals/evaluate goals.0.versions.1.version, POST /conversions/subids subids.1
  • Changed: Numbers and switches that handlers read from a body themselves are read strictly, a 422 naming the field (and its range) where a cast used to guess: on POST /conversions click_id "12abc" recorded the conversion on click 12 and conv_time "2026-10-07" dated it in 1970; LTV merges took source_customer_id or source_company_id "12abc" as record 12, and an LTV occurred_at of "2026-10-07" was 2026 seconds; a rotator public_id "abc" silently took a generated id; a sync job's max_attempts and the sync worker's limit were clamped. A sync job's flags (force_update, skip_errors, dry_run and the rest) and a custom field's is_required take true/false, 1/0 or their strings and nothing else: "false" read as true, so a job sent force_update, skip_errors and dry_run as "false" was queued with all three on, and is_required "false" made a required field. A subscription's started_at, amount and interval are read to their columns ("2026-10-07" was stored as 2026 and an amount of "abc" as 0), the amount is required on an upsert, and a period end past 2106-02-07 is refused instead of a 500
  • Changed: time_from and time_to on GET /reports/*, /clicks, /conversions, the LTV reads and GET /attribution/reports/breakdown take unix seconds, a date (YYYY-MM-DD: time_from from its first second, time_to through its last, in the account's time zone) or a time with its offset (2026-10-01T09:30:00Z). They were cast to integers, so 2026-10-01 meant 2026 seconds after 1970 and the answer covered all time with nothing said; milliseconds (named as such), a day that does not exist, a time without an offset and a range whose start is after its end are now a 422 naming the field. 0 still means no bound. p202 ltv passes the window as given; p202 attribution still takes unix seconds only
  • Changed: Query parameters are applied or refused, never ignored or clamped. GET /reports/* refuses an unknown parameter, a list or a malformed value (campain_id=7 answered for every campaign), a breakdown sort that is not a metric column (it became total_clicks), and a sort_dir, limit or offset out of range; GET /clicks took three ids and a window and ignored everything else (keyword=shoes listed every click), and now refuses what it does not take. Every CRUD list, conversions, rotators, the sync change feed, its audit log, the error log and the LTV reads take their whole-number parameters (limit, offset, cursor_ttl, from_epoch/to_epoch, the LTV reads' days and months, and updated_since where it is taken) only in the documented range, or answer 422 naming the parameter and the range (limit=1000 answered 500 rows that read as all of them, limit=abc one row, updated_since=yesterday every row). A CRUD list's filter[...] on something it cannot filter by is a 422 naming what it does filter by (filter[aff_campaing_id]=3 answered every row), and read-only fields and the id are now filterable. GET /ltv/customers refuses an unknown sort or dir (it ranked by total revenue instead), the other LTV choices (segment, by, status) name the parameter and its values in field_errors, and GET /audit/sync-jobs refuses an unknown format or status (it answered JSON, or an empty list)
  • Changed: "0" is a value, not an absent parameter. GET /ltv/summary, /ltv/customers, /ltv/breakdown and /ltv/predict refuse period=0, which answered over the default window as though that were the one asked for; GET /conversions?campaign_id=0 is a 422 where it listed every campaign's conversions (as p202 conversion list --aff-campaign-id 0 sent it), and so is a non-number, which listed none; a CRUD list's cursor=0 is refused as every other malformed cursor is; and on POST /conversions a given customer_id must be a positive id, while a customer_ref of "0" now reaches the customer lookup instead of being dropped
  • Changed: An id in a REST path is read strictly: anything but the digits of a positive whole number is a 404 Not found: "1e3" is not an id. Every route cast the segment, so DELETE /campaigns/2e0 deleted campaign 2 (and its dry run previewed it), PUT /aff-networks/2x renamed category 2 and GET /aff-networks/2abc read it
  • Changed: ?dry_run=1 on a POST, PUT or PATCH previews or is refused, never ignored. The Update routes and POST /system/retention/delete-before preview; every other write answers 422 "This route has no preview; remove dry_run to perform the write." before anything runs, where the parameter was ignored and the write ran (POST /aff-networks?dry_run=1 created a category). A misspelt dry_run value is a 422, and dry_run beside staged is refused on any write
  • Changed: updated_since on a list is taken only where the records keep an update time (GET /forecast-events) and is a 422 elsewhere, pointing at GET /changes/{entity}, which takes it; deleted_since is a 422 on every list, which answers live records only. The lists looked for those columns with a statement the database will not prepare, so neither filter could work. A server-side sync job's body may not carry updated_since (send "incremental": true): an incremental job reads the source whole and skips unchanged records by its manifest, and sends no time filter
  • Changed: A date field (a forecast event's event_date and end_date) takes a real day written YYYY-MM-DD or is a 422 naming it; "", 2026-02-30, 27/11/2026 and a time answered 500. A date that may be empty is cleared with null, which p202 forecast-event update --end-date "" now sends (it sent "", the 500), and the CLI checks date flags before any request. filter[event_date] with something that is not a date is a 422 instead of an empty list
  • Changed: The REST API answers money and counts as JSON numbers rather than MySQL's decimal strings: the summary, breakdown and timeseries reports' metrics (an empty summary answers 0 where it answered null), a record's DECIMAL fields (a campaign's payout, a tracker's CPC), GET /clicks, /clicks/{id}, /clicks/{id}/conversions and /conversions, the LTV summary and /ltv/cohorts. A record's version changes once with this, so a client holding an older one gets the documented 409 and re-reads. POST /clicks/cpc's cpc and a revenue upload's totals stay exact decimal strings. The Go CLI reads a number or an older server's string alike, and its tables keep up to four decimals for an amount finer than cents
  • Changed: An API key is held to its user's role as the pages hold that user. Setup records (campaigns, categories, traffic sources and their accounts, trackers, landing pages, text ads, redirectors and their rules) need access_to_setup_section to read or write, GET /trackers/{id}/url and the Mobile Apps reads included (the install token stays open), and a delete needs the record's remove_* permission as well, checked first; a dry run and a staged write are refused as the write is, and a staged change is applied under the applier's current role. Reports, rotator stats, clicks and conversions stay open to every role, but for a role without access_to_campaign_data the absolute clicks, click-throughs, leads, income, cost and net (and a click's or conversion's cost and payout) are null, the ratios kept, beside masked: true, as Overview and Analyze show them. POST /conversions needs access_to_update_section, and DELETE /conversions/{id} delete_individual_subids as well. /system/version, db-stats, cron, errors, dataengine and metrics need access_to_settings beside Admin, as the rest of /system does
  • Changed: Every /goals route needs access_to_setup_section, reads, validate and evaluate included, as Setup › Campaigns and Setup › Mobile Apps do; creating, editing, archiving or re-evaluating a goal that is not a campaign's (an app's or the account's) also needs manage_attribution_models. For a role without access_to_campaign_data a goal's payout and value.amount, and an outcome's value, are null beside masked: true.
  • Changed: Your own preferences, API keys and time zone need access_to_personal_settings, which Personal Settings asks for before showing them: GET and PUT /users/{id}/preferences (which hold the account's integration secrets, privacy setting and tracking domain), the API key routes and their delete preview, and a PUT /users/{id} that changes your own user_timezone. user_active, like user_name, needs a user you may manage, yourself included. Your email and password stay yours to change
  • Changed: /users keeps Account › User Management's rules. Another user's profile, password, API keys, identity key and preferences now need Admin, user 1's only user 1, and an Admin or Super user account add_edit_delete_admin as well; role 1 is never granted, role 2 only by a Super user, and user 1's roles are fixed; a delete needs add_edit_delete_admin and never removes user 1 or yourself. Changing your own password needs current_password, and passwords are 8 to 72 characters. A user can be renamed (p202 user update --user-name) under the page's rule; a name or email another account has is a 409 or 422 instead of a database error, user_timezone must be a zone PHP lists, and user_active must be 0 or 1 ("abc" deactivated the account). p202 user update --set-password reads the new password without echo (and --current-password the current one), and user create reads the password from piped stdin and takes --user-active
  • Changed: A deactivated user's API keys are refused on v1, v2 and v3 with a 401 saying the account is deactivated. The Go CLI's hint for that 401 names p202 user update <user_id> --user-active 1
  • Changed: A Setup record created or updated through the API may link only to the caller's own live records: a category, campaign, landing page, text ad, traffic source, traffic source account or redirector of another account, or one removed, is a 422 whose field_errors name the field and the list route that has valid ids. 0 means none where the link is optional, and aff_campaign_id 0 is still taken where Setup stores it: an advanced landing page, and a tracker that names a redirector or an advanced landing page. An update re-sending a link the record already holds is not checked again, so a record whose campaign was removed can still be saved whole. bulk-upsert error rows carry field_errors, and p202 import of another account's export is refused row by row (p202 sync maps the ids)
  • Changed: GET /trackers/{id}/url answers the link Get Links builds: on the caller's tracking domain (a domain stored without a scheme takes the request's; with none stored, the host the request used, where it answered 404) with the install's directory, then t202id, the traffic source's live custom variables (from the account's own source only), each built-in token given a value, and t202kw; a landing page's variables go before its #fragment. The tokens are query parameters (c1-c4, utm_*, t202ref, t202b, t202kw), and an unknown parameter or a value holding &, #, ?, whitespace or a control character is a 422; it used to append fixed placeholders (c1={c1} to c4={c4}) no traffic source fills. p202 tracker get-url, create-with-url and bulk-urls take a flag per token, checked before any request, and when create-with-url creates the tracker but cannot fetch its link the hint names the tracker_id and get-url
  • Changed: A tracker made through the API takes its campaign's cloaking (click_cloaking -1, as Get Links does) unless told otherwise, and click_cloaking accepts only -1, 0 and 1 as sent ('0.5' and '1e0' were cast). A CPC and a CPA in one request is a 422, an update that sets one switches the tracker to it and clears the other (a CPC tracker given a CPA was charged per click and per action), and a cost outside 0 to 99.99999 is a 422 rather than a database error. tracker_id_public, the t202id in a link, is a 422 when another tracker in any account holds it, and the API and Get Links draw only free ones: a shared id made each click an arbitrary pick between two trackers. Existing shared ids are left as they are
  • Changed: The /rotators API checks a destination and a rule whole before writing anything. A default or a rule redirect is exactly one of a URL (http or https), a campaign or a landing page of the caller's that is not removed; criterion types are the ones the redirects evaluate, with is or is_not; a country is written as the redirects read it ("United States(US)"; a bare US never matched); weights are 0 to 100; and an ip value is a list of single addresses, stored in canonical form, anything else (a typo, a range) refused naming criteria.N.value. A rule update refuses a malformed entry instead of skipping it and writing the rest. Setting a default replaces the whole default and clears the auto-monetizer, as the page does (a default_url set beside an old default campaign was never used), and reads return auto_monetizer. A GET body sent back with PUT changes nothing it only restates: an auto-monetizer default stays, and a removed campaign the rotator or a rule already names is taken as it is, while naming a removed campaign anew is refused. p202 rotator rule-create takes --status
  • Changed: POST /conversions refuses what it used to record wrongly or drop with a 2xx. A transaction id the click already has, sent with another payout, conv_time, customer or line items, is a 422 naming transaction_id (it answered 201 duplicate: true and dropped the second sale); the same request still replays, and pixels and postbacks keep the first. items or customer_crm with no customer to attach them to (none named, none linked to the click, none from the account's customer c-param) is a 422 naming each, with nothing written and the Idempotency-Key unspent (it recorded the sale without them); gpb.php, gpx.php and upx.php keep the conversion and log the line items they did not store. A reversal_id without status "reversed" is a 422 (it recorded a new sale). transaction_id and reversal_id must be a string or a JSON integer (a very large integer arrived as a float, so two orders collided; an object was stored as "Array"), at most 255 bytes (a 500 before), and the payout must fit the ledger's decimal(11,5) (a 500, or 999999.99999 stored). p202 conversion import compares the payout and time of a row whose transaction id the click already has: a difference is the status conflict, not sent, exit 5, where it was reported duplicate and dropped
  • Changed: The LTV writes say what they did. POST /ltv/revenue with a used idempotency_key and another amount, customer or line items, and a subscription event resent under its key or transaction id with another amount, are a 422 naming the key and what differs (they answered duplicate: true or changed: false and recorded nothing). POST /ltv/webhooks refuses an event name this install never sends, naming events.N with the five it sends and "*" (revenue.recoded made a hook that heard nothing, 201). Every LTV 422 names its field in field_errors (amount, items.0.quantity, custom_fields.tier, ...), a repository's own failure is a 500 rather than a 422, and deleting a company with customers attached is a 409. customer_ref must be a string and customer_ref_type one of its list (an object made a customer named "Array"), and POST /ltv/revenue's external_ref and transaction_id are at most 255 characters and idempotency_key 191
  • Changed: GET /reports/*, the LTV reads and the attribution reports count periods in the account's time zone, as the report pages do: today and yesterday from the account's midnight (they used the server's), and last7, last14, last30 and last90 as today so far plus the N whole days before it (they were N×24 hours to the second, up to a day less than the page's Last N Days). Timeseries buckets, daypart and weekpart are the account's days and hours too: timeseries grouped in the database connection's zone, and daypart and weekpart fell back to UTC hours without a word on a server without MySQL's time-zone tables; timeseries now says which zone. The CLI's --days N stays a rolling N×24 hours
  • Changed: An account's time zone is one rule on the pages and in the API: a zone name PHP lists, the backward-compatible names (Europe/Kiev) included, spelled as listed; anything else reads as UTC. A stored offset such as +05:30 counted UTC days on the pages while the API grouped by the fixed offset; it is UTC in both now. Personal Settings offers the account's own zone first and selected, so a backward-compatible name is no longer replaced by Africa/Abidjan when the page is saved for another reason, and PUT /users/{id} accepts such a name, so the account's own GET body goes back
  • Changed: /capabilities' server.timezone_support is always named-timezone: every report converts times with PHP's zone database, so whether MySQL has zone tables no longer matters, and fallback-only and unknown are no longer sent
  • Changed: PUT /users/{id}/preferences holds every value to the rule of the page that owns it and refuses an unknown key with a 422 naming it (it was dropped with a 200). user_daily_email is "" (never) or an hour 00-23; the "off" the docs suggested failed the column. It also writes what the pages write that it could not: the keyword, referer, cost source, privacy and ad settings, the ClickBank, Zaxaa and JVZoo keys (a changed cb_key resets its verification) and the four LTV settings; chart_time_range is now the Overview chart's own setting (it was written where nothing reads it). A currency change re-prices every campaign's payout, as Personal Settings does, fetching the rates first: a rate the service does not give is a 502 and nothing is written. p202 user prefs update has a flag for each column, checked before any request (--user-daily-email never)
  • Changed: The Go CLI reads a masked answer for what it is: a table or CSV of one prints a note on stderr, --ndjson lines carry "masked": true (click and conversion list --all and click list --follow too), and --json shows it as sent. Commands that compute from the hidden figures (campaign optimize, report losers and winners, forecast, analytics --split-at, list --with-stats, report summary --all-profiles) refuse it with exit 2, naming the permission and p202 whoami, instead of reading nulls as zeros, and so do report breakdown's --min-clicks, --min-cost, --zero-leads and a --having on a hidden figure; a filter on a ratio still runs and keeps the flag
  • Changed: The Go CLI no longer exits 0 for what did not happen. A confirmation nobody can answer (no terminal and stdin at its end: an agent, a script, a pipe) is a validation error, exit 1, saying nothing was done and naming --force (and --dry-run where the command has it), where it printed "Cancelled." and exited 0; an explicit no still cancels with exit 0, and user apikey rotate keeps the old key and says so. p202 import and p202 sync with --skip-errors exit 5 when any record failed, and a 401 or 403 stops them; a server-side sync job that ends failed, partial or cancelled exits 5. report breakdown refuses a --having it cannot read ('roi<abc', a field no row has, NaN) with exit 1, where it dropped the filter or read the field as 0
  • Changed: The Go CLI's create and update flags are the fields the server writes. Five that saved nothing are gone (campaign --aff-campaign-cpc, --aff-campaign-postback-url and --aff-campaign-postback-append; aff-network --aff-network-postback-url and --aff-network-postback-append); a campaign's payout and category, a landing page's nickname and a text ad's headline, description and display URL are required, and a create names every missing flag at once, with the list command each id comes from, before it builds a client. On update an explicit "" clears campaign --aff-campaign-url-2 to _5, landing-page --leave-behind-page-url and forecast-event --end-date, --tags and --notes; every other flag refuses an empty value. The help says that --aff-campaign-id 0 is the value for an advanced landing page and a redirector's link
  • Changed: p202 analytics --split-at refuses every --period, last7, last30 and last90 included: those start at the account's midnight, which the CLI cannot compute, and it split a window starting N×24 hours ago under the period's name. Use --days N (the default window is now labelled "default --days 90") or --time-from
  • Changed: p202 search hands back up to five candidates, each with its coverage, and good_match when the first matched at least three quarters of the words. It finds a command by the web UI page that does it and by the words people use for the task ("spy", "real-time traffic" and "tail clicks" offer p202 click list --follow; edit, modify, change and set read as update), lists in unknown_terms the words no command mentions instead of guessing at them, and fails only when nothing matches or the query names a page no command does, saying where that is done. --quiet prints the first path alone and only for a confident match (it printed a weak guess). p202 commands --brief lists every command on one line with the UI page it does, about 6,000 tokens, and search's hints point at it; commands --json lists each command's tasks and the pages no command does; search and commands print one-line JSON for an agent
  • Changed: p202 conversion postback-url prints the postback URL the install hands out, on its tracking domain with the network's own macros (--subid '{aff_sub}', --amount '{payout}', --campaign for the advanced form), where it printed the API URL's host with placeholders no network fills. Stdout is still the URL alone, also with -q, and p202 conversion pixel prints one pixel the same way
  • Changed: p202 config test also asks the instance with the key and exits 2 when it is refused, naming p202 config set-key: it called only /system/health, which answers before authentication, so a made-up key passed
  • Changed: Both CLIs write every flag in kebab-case (--aff-campaign-id) in their help, errors, hints and documentation, and accept the snake_case spelling (--aff_campaign_id) as before, so scripts keep working. bin/p202 declared its options in snake_case and the Go CLI's errors named some flags that way
  • Changed: GET /clicks and /clicks/{id} answer a click's rotator_id and rule_id from the rotator's own record of the click, with rule_redirect_id beside them: rule_id was the chosen redirect's id, and a landing page's offer-rotator clicks read as routed by no rotator. A click no rotator routed and a rotator's default both answer null
  • Changed: The API's server-side state (idempotency records, staged changes, sync jobs, rate limits) never takes a directory it cannot show is its own. The shared pre-1.9.75 /tmp/p202-api-v3-state is no longer adopted; the log says once how to carry it over (move it, or set P202_SERVER_STATE_DIR to it). The temp-directory default is used only when this process's user owns it and no one else can write to it, else the first such .1 to .3, and a process with no database identity writes a -unscoped directory of its own. A reinstall under the same database name still finds its predecessor's directory: remove it or set P202_SERVER_STATE_DIR first
  • Changed: The impression pixel (tracking202/static/ipx.php) answers its transparent GIF and writes nothing. The table it wrote to was never created by any install and nothing read it, so each impression, and every click carrying its cookie, logged a failed query
  • Changed: Update › Upload Revenue Reports lists the first 1,000 lines it did not record and says how many more were skipped and why, by reason; its count of skipped lines is every one. It listed every line, which for a report read with the wrong subid column was the whole report
  • Fixed: The installer no longer opens on an instance that is already installed. Installs completed before this are unaffected
  • Fixed: A fresh install could not get past the license-key step. Saving the key on the fetch-a-key page checked it through an endpoint that only answers a signed-in user, and before the first account exists nobody is signed in, so a valid key was refused as "not valid" and the installer never opened. The installer now validates the key itself when you submit the account form, with the same license service, and refuses to install a key it cannot confirm; the fetch-a-key page carries the key straight to the installer. Installs completed before this are unaffected
  • Fixed: One click from an address in a listed bot range (Google, Microsoft, Facebook, Apple) marked every later visitor with the same browser as a bot. The address check ran inside the user-agent parse, which is cached per browser with no expiry, so a common browser seen once from such an address recorded every later click with it as click_bot = 1 and click_filtered = 1, dropping real visitors out of "Real clicks" and attribution's clicks and cost. The address is now judged on each click after the cache read, the cache holds only what the browser says, and entries cached by an earlier version are parsed again. Clicks were always redirected normally; only how they were recorded was wrong
  • Fixed: Rotator links (rtr.php) never checked a click's address against the listed bot ranges (Google, Microsoft, Facebook, Apple), so ad-review and crawler clicks from those addresses were recorded as real visitors there. rtr.php keeps the visitor's address as a plain string, and the check read it only in the form the other click pages use; it now reads both, so a rotator click from those ranges is click_bot = 1 and click_filtered = 1, as on every other link
  • Fixed: A web address in a chat message showed as plain text in the chat box, so a link the team sent had to be copied by hand; an http:// or https:// address is now a link that opens in a new tab, in the team's replies and your own messages alike. Nothing else becomes a link (javascript:, data:, mailto: and a bare www. stay text), trailing punctuation and a closing bracket the address did not open stay outside it, an address ends at an invisible or direction-changing character (so a right-to-left override cannot disguise where a link goes), an address longer than 2,000 characters stays text, and a message is still never read as markup
  • Fixed: Automatic click-data deletion (Account › Settings › "Delete click data older than N days", PUT /system/retention, p202 system retention set) deleted almost nothing: the cron job took the smallest id among the expired clicks and deleted the ids below it, and since click ids are not in time order that could also take a newer click with a smaller id. It now deletes exactly the clicks all of whose rows are older than midnight N days ago, so an install that has the setting on starts deleting what it promised on the first cron run after upgrading — a backlog drains in batches of 1,000 clicks, each from every click table in one transaction, up to 20 seconds a run. Both deletions (this one and the scheduled "delete click data from before") share that routine (Prosper202\Click\ClickRetention), and it now also deletes the click rows the old list missed: 202_facebook, 202_clicks_rotator, 202_cpa_trackers and the identity graph's per-click 202_identity_observations and 202_clicks_visitor; conversions, attribution journeys and credits, LTV and app records are kept. The deletion marks the attribution reports' rollup in the same transaction, so it is summed again from the clicks that remain instead of keeping sums for deleted clicks the Overview no longer counts. The scheduled deletion's Slack notice is sent once, when it finishes, instead of on every cron run while a marker was set
  • Fixed: The scheduled deletion (Account › Settings › "Delete click data from before ") stored the newest click id from before the day and deleted every id below it, so a click re-clicked on the day was deleted and the marker's own click was kept. It stores the day's start (202_users_pref.user_delete_data_before, added by the 1.9.77 upgrade) and deletes every click all of whose rows are older, by the automatic deletion's own query, which the preview counts by too; a deletion already scheduled by id is carried out as stored. POST /system/retention/delete-before and p202 system retention delete-before schedule the same way. The page read the day with today's time-zone abbreviation ("IST" is Israel's to PHP, so India and Ireland were cut hours off) and took a rolled-over date (2001-02-31) or a day after today, which deletes every click in every account; it reads the day as the API does, in the account's zone, and refuses those naming why
  • Fixed: Account › Settings read and wrote the click-data deletion settings on the signed-in user's row while the cron job reads user 1's, so an Admin other than user 1 saved a setting nothing acted on and was shown it back. It reads and writes user 1's, as the API does; AutoCron and ISP lookup stay per user
  • Fixed: Every advanced landing page's campaign link (go.php?acip=, and its PHP redirect to off.php) answered 500 for a visitor whose browser did not send back the click cookie (third-party cookies blocked): off.php passed date()'s strings to mktime() under strict types. It passes integers, as dl.php and record_simple.php already did, and StrictTypesMktimeArgumentsTest refuses the shape in any strict-typed file
  • Fixed: A 403 for a missing role permission got the CLI hint to check the API key; it now says the key is fine and how an admin grants a role that has the permission (p202 user role list, p202 user role assign), for every route that asks for one
  • Fixed: A URL or landing-page redirect made through the /rotators API sent every visitor its rule matched to an empty Location: the API stored 0 in the parts a redirect does not use, and the redirects read that 0 as a campaign. The API stores NULL there, and rtr.php and offrtr.php read 0 as none, so redirects already saved with zeros work as written
  • Fixed: Pages that should read only the signed-in account's clicks now do: Visitors, its download and Spy, the click breakdown (a click not in your account is now "There is no click … in your account", 404), the Overview chart's "Clicks (all)" and its campaign lines, and the daily email (202-cronjobs/daily-email.php). The daily email also failed under strict types whenever the day had a click, so it was never assembled; it is user 1's own campaigns now
  • Fixed: A record that names another account's record by id (a tracker's campaign written before links were checked, a campaign's category, an account's traffic source) no longer lends that account's names, URLs, payouts or settings. Every read ties the named record to the row's own account, so the row is still counted and the foreign name is simply absent: the API's report breakdowns and GET /conversions, the attribution and LTV reports, the legacy report pages (Overview, Analyze, Group Overview, Visitors, Spy and its download, the Rotator Breakdown, the filter menus), the Setup pages (Get Links, Campaigns, Update CPC's menus, Get LP Code, the home page's Get Started and the Slack notices), the legacy API's WordPress feed and the 1.9.x upgrade step that names a rotator rule's redirect. The rollup writes NULL for a campaign, category, traffic source or text ad that is not the click's account's. Get Links lists a link whose landing page or redirector is not the account's with a "no landing page" or "no redirector" warning and no link to copy (it built "http://?t202id=…")
  • Fixed: off.php and offrtr.php act on the click the request names, within its account. The address-based guess at the visitor's last click runs only when nothing names a click, and a click of another account than the offer's or rotator's is refused as an unknown one is
  • Fixed: Get Links built a landing-page link by hand: t202kw= with no & onto the id (?t202id=12t202kw=, an id no tracker has, so a copied link recorded nothing), after the page's #fragment, without the URL's port or the traffic source's variables. It builds the link GET /trackers/{id}/url gives
  • Fixed: With no tracking domain stored, every tracker link, landing-page snippet and postback URL a page handed out (Get Links, Get LP Code, Postback/Pixel, the integrations page, landing.php) named the address the web server listens on, which behind a reverse proxy or a published container port nobody reaches; they name the host the request used, as GET /trackers/{id}/url, GET /conversions/postback-code and GET /system/integrations do. The API builds its links on the caller's own tracking domain, as the pages do, and landing.php takes HTTPS from a TLS proxy. A failed read of the stored domain is an error instead of a fallback to the request's host
  • Fixed: The keyword, referer and IP filters read the same on Analyze, Visitors and the Group Overview as in the API: the text was pasted into LIKE (g3 50% matched "g3 500 off"; % and _ are literal now); the Analyze pages' referer filter built an id list the server cuts at its group_concat_max_len (160,000 matching URLs showed 149,796 clicks); Analyze took the first of an address's stored rows, Visitors matched the address as a substring (10.0.5.1 showed 10.0.5.12) and the Group Overview showed nothing for any IPv4 filter; Visitors matched only the referer's domain, and the Group Overview counted four clicks sharing a referer as 16. The IP filter is exact everywhere, and the Overview and the report-filter form refuse part of an address with a sentence
  • Fixed: Group Overview put a c1 to c4 of "0" under "[No c1]" (it is its own row), showed whichever campaign's payout the server read first (it is income per lead), dropped clicks at the Redirector level when a redirector had been removed or was another account's, and answered a database error for Redirector with Rule
  • Fixed: Overview › Rotator Breakdown counted a rule's clicks by the id of the redirect rtr.php chose (rules that took 6 and 2 clicks showed 1 and 5) and left out every click a landing page sent through an offer rotator. It reads each click's rotator and rule as GET /rotators/{id}/stats does, lists a deleted rule that has clicks, and shows Payout as income per lead. offrtr.php records a click no rule matched as the default (it was filed under the last rule tried), writes the rotator and rule onto the click, and rolls up every click it sends, not only a campaign's
  • Fixed: A landing page's offer rotator (offrtr.php) sent every click a rule matched to the rule's first redirect whatever the weights (a redirect weighted 0 took them all), and a rule's landing-page redirect to the rotator default's landing page; it chooses by weight as rtr.php does and sends to the chosen page. A rule whose weights are all 0 or blank sends to its first redirect, where rtr.php answered its visitors with an empty 200 and a blank weight threw
  • Fixed: A redirector's IP address rule compared text, so an address written in another case, with its zeros written out, or after a space following a comma never matched. Both sides are compared as addresses, rows already stored included; Setup › Redirector refuses a value that is not a list of single addresses (a typo, a range) with the reason and stores the canonical list, as the API does
  • Fixed: Click cookies reach the browser and their readers. Their Domain carried the Host's port, so on an install served from a non-default port (:8080, :8443) the browser dropped every click cookie, lp.php never set click_out and no pixel found its click by cookie; the Domain is the host without its port, and there is none for an IP address or a dotless host. A Host header with a character a cookie refuses made dl.php answer 500 after recording the click. go.php's campaign cookie was scoped to /tracking202/redirect/, so the pixels never received it and converted the visitor's last click in any campaign; it is set for the whole site. Every reader falls back to the -legacy twin of each click cookie, so on a plain-HTTP install, where a browser drops the Secure cookie, lp.php and the pixels find the click (a landing page or pixel on another site still cannot over plain HTTP, as the FAQ says)
  • Fixed: The install's own URLs follow the request. The cloaked redirects of dl.php, lp.php and rtr.php, record.php's outbound cookie and script variable and the stored cloaking URL were built as http:///, without the port, the install directory or HTTPS, so on a non-default port, behind a TLS proxy or in a subdirectory the visitor was sent to an address that refused the connection; they take the request's scheme and Host (validated, else the server's own name) with its port, and the install directory. The cloaked pages' forms and their 404 redirects (off.php, offrtr.php, lpc.php, cl.php, cl2.php) were root-absolute and answered 404 on an install in a subdirectory
  • Fixed: A forwarding header's address is used only when it is a valid address; anything else falls back to the connecting address. One reader, with the same header precedence as before, serves the click path, the pages, the sign-in throttle and conversion rows, and an address is stored in canonical form, so 2001:DB8::1 and 2001:db8:0::1 are one visitor
  • Fixed: The privacy setting (Personal Settings) does what it says. "Enabled for European Traffic" did nothing (it tested a session key nothing sets, and every click logged a warning); it holds back for a visitor GeoIP places in Europe or cannot place, and for every visitor where no GeoIP database is installed. Under privacy every stored visitor address is the masked one (/24, /48): the click path stored the full address on clicks and conversions and masked only a variable nothing stored, the app intakes (Android installs, SKAN and AdAttributionKit postbacks) stored the device's address as it arrived, and the opt-in click tracer's log wrote full addresses. The IPv6 mask kept the wrong groups of a compressed address (::1 became 0:0:1::). The Android intake stores the forwarded address, as the Apple receiver does. Under privacy "don't count my own clicks" compares the address a click arrived from with the sign-in's, so the owner's clicks are still filtered and a neighbour in the same /24 is not, while the duplicate-click check, which has only the stored address, treats a /24 as one visitor for a day
  • Fixed: The privacy setting in force is the stricter of the install's and the link owner's: the click path applied only the install's (user 1's), so an account set to hold back had its visitors stored unmasked and given every cookie. Each endpoint names the owner before it stores an address or sets a cookie, and holds back until it has; the p202vid identity cookie, the click cookies record.php writes on the landing page's site, the personalization token and go.php's 202v cookies (set for any id a link named, now only for a click that exists) are governed too. With memcache working, the redirects read a cache key nothing writes and tracked everyone in full; a stored value that is not one of the settings holds back instead of tracking in full, and so does a read that fails, which is logged
  • Fixed: A pixel or redirect that names no click (no subid, no cookie: off.php, px.php, gpx.php, upx.php and rtr.php's ?lpr=) looked for the address's last click by REMOTE_ADDR, which behind a CDN or load balancer is the proxy's, so it credited whichever visitor came directly, and it never matched an IPv6 visitor. It looks up the address the click was stored under, IPv6 included; under privacy, where that address is masked, it credits a click only when exactly one in the window matches and logs the miss. rtr.php's ?lpr= looked back to any click ever and now takes the pixels' 30 days
  • Fixed: The report pages count in the account's current time zone: they used the copy the session took at sign-in, so after the account's zone changed elsewhere every page counted days from the old midnight until the next sign-in; the zone is read from the account on every request, and a read that fails fails the page. Named windows are calendar days (yesterday and the last 7 days were a day off after a DST change), and Visitors keeps a day's first and last second. Day Parting, Week Parting, Breakdown Analysis and the Overview chart convert each click by the offset in force at its own time: they set the database connection to today's offset rounded to whole hours (India ran at +06:00 and a January click in New York counted at October's offset; the engine the pixels and the revenue upload use could not set an offset east of UTC at all) and left it set for the rest of the request, where the cron's attribution exports grouped in it. Breakdown Analysis grouped 7 September and 7 October as one row; it groups by the calendar date. The Overview chart's points step through the account's days and hours and end on the window's last one (today was drawn as two days)
  • Fixed: The attribution reports' group_by=day, and exports grouped by day, are the account's days, in the full computation and the rollup alike; they were the database connection's days, and UTC days in the cron's exports. meta.timezone says which zone. GET /ltv/cohorts groups customers by the account's calendar months (a customer near a month's edge could fall in the month before) and answers timezone. A click stamped 0 in a zone west of UTC no longer fails a whole report
  • Fixed: Analyze › Mobile Apps' This Month ran to the last day of the month; it is the 1st through today, as on every other page
  • Fixed: A report query that fails is an error, not an empty account: Visitors drew "Clicks 0-0 of 0" above the rows it listed, every report page's window became 0 to 0 when its preferences could not be read, the Analyze pager showed one page, the variable and per-source reports read "No clicks in this range" and the chart drew zeros. The pages show the database error page or "The report could not be read … This is not a statement that there were no clicks", and the Overview's chart and tables each show their own notice where either failure was a fatal error
  • Fixed: The Overview chart's range switch and builder (charts.php) answered PHP's error text for a failed query; answered 404, or saved nothing under a 200, for an account with no saved chart (every account the API creates, among others; such an account now gets the installer's default chart); stored a range or figure the chart cannot draw (now a 422 naming it); and now requires access_to_campaign_data for the figures (a 403 without it)
  • Fixed: Overview tables: the no-campaign row's Avg payout read "$" (every row is income over leads now), a click naming another account's landing page made a second "[direct link]" row, no advanced landing page click was ever listed by traffic source, and the per-campaign table could name a moved campaign's old category and was refused under ONLY_FULL_GROUP_BY. "No traffic source" on the report pages' filter and the summary form (and the API's ppc_network_id=none) counts rows rolled up with 0 as well as NULL
  • Fixed: Report rows rolled up again refresh every column they copy (the traffic source and text ad were left stale), and moving a traffic source account to another source or a campaign to another category, on the pages or through the API, re-rolls their clicks: the API's breakdown by source had put six of seven clicks under the old one. The cron job's rebuild of 202_dataengine_job windows without curl rolled up user 1's clicks only and never finished its first window; it covers every account and marks each window done. dej.php answered 200 for an hour whose rollup failed, so the hour was marked processed and never retried; it answers 500, with its output buffered so a notice cannot fix the status. A cookie-less lpc.php request no longer re-rolls user 1's latest click from the visitor's address. The attribution rollup no longer leaves a conversion hour stale when a click it credits outside the conversion's journey changes
  • Fixed: The report rebuild's call to this server (202-cronjobs/process_dataengine_job.php fetching dej.php) calls user 1's stored domain, or the listener that served the request pinned to this machine with no redirect followed, and times out
  • Fixed: The addresses handed to the hosted service at my.tracking202.com (AutoCron, the daily email, DNI and the deeplink pixel) are the install owner's stored tracking domain with its scheme, else the server's own name; they were always http:// (read from SERVER_PROTOCOL). Only the owner's profile save registers the daily email. dni.php and daily-email.php authenticate their callers more strictly. The AutoCron toggle says when the install's address cannot be read instead of answering 500
  • Fixed: Thirty places compared the session token inline; every check now goes through one comparison, and a session holding an unusable token gets a new one (from random_bytes) instead of being refused on every form until sign-out. The update banner's snooze asks for the token too
  • Fixed: The Setup pages' ajax endpoints (saving a tracking link, a redirector's rules or a traffic source's variables, landing page code, DNI offers, deleting a tracker) now require access_to_setup_section, the variables endpoint remove_traffic_source as well, and a redirector save that would remove a rule needs remove_rotator_rule
  • Fixed: The password reset email's link is built on the install owner's stored tracking domain; with none stored the email names the page and the key without a link, and the log says which setting brings the link back
  • Fixed: A failed database read at sign-in is an error, not an answer: the login throttle's failed read counted no failed attempts in silence (it is logged), a failed credential read reported invalid credentials, the API-owner and license-owner lookups chose the user itself (sending a sub-user's whole session to the license page) and the license key read as none. Each throws and is logged, and a sign-in whose owner lookup fails stays signed out
  • Fixed: A failed database read in the API is a 500, not an answer: a record read as missing (an update or delete answered 404 for a row that exists, and bulk-upsert created a second copy) or a list read as empty
  • Fixed: Setup › Traffic Sources could not remove an account's only pixel (clearing its code kept it firing), and every save of the account stripped a level of backslashes from a Raw pixel ("line\nbreak" became "linenbreak"). A save leaves exactly the pixels the form lists, a Raw pixel is stored as typed (a browser's CRLF as \n), a code with no type is refused instead of dropped, and the Slack notices compare each pixel with its own previous version
  • Fixed: A traffic source's removed custom variable stayed on every link Get Links listed and was still recorded on clicks by dl.php and the landing-page recorders and script, and one removed and added again under the same parameter was recorded twice, so Analyze › Custom Variables counted the click twice. Each reads the live variables in order, and the report names a removed variable "(removed)"
  • Fixed: Visitors and its download showed an IPv6 visitor's address as the id of its 202_ips_v6 row ("1"); they show the address
  • Fixed: A landing-page click logged a dozen "Undefined array key" warnings when its referer carried no search query or no tracker was named, dl.php dropped a traffic-source variable named "0", and get_custom_vars.php answered [""] for a source with no variables. An advanced landing page's offer click on a link whose cloaking is off got the cloaked page when its campaign cloaks; it goes straight to the offer
  • Fixed: Report pages showed a negative amount as "($-5.00)"; it is "($5.00)"
  • Fixed: Upload Revenue Reports (the page, POST /conversions/uploads and p202 conversion upload-revenue) took the first header with any money word as the commission, so "Date,Sub ID,Sale Amount,Commission" recorded each order's total and "Sale ID,Sub ID,Commission" each sale's id; the words are ranked (commission, payout, earning, revenue, income, amount, sale) and a header that also names an id, a date, a count, a rate or a status is never the commission. A comma was deleted before reading a cell ("12,50" was recorded as 1250, "1.234,56" as 1.23456); it is read only between groups of three digits, and a cell with any other comma is skipped as "the commission is not a number". p202 conversion import reads payouts the same way
  • Fixed: Update › Upload Revenue Reports read a CSV line longer than 100,000 bytes (a long description or URL column) as two lines: the piece after the cut was skipped as a line with no subid, every later line's number moved by one, and a line whose subid or commission came after the cut was skipped. A line is read whole, however long
  • Fixed: Update › Reset Campaign Subids counted a click visited again (a rotator re-click) once per visit in its preview and its result; it counts the click once
  • Fixed: A server-side sync whose force-update met a target record changed since it was read recorded "Internal server error" and, without skip_errors, was requeued, and the retry wrote over the change the 409 protected. Each failed record is named with its operation and the target's answer; a 409 is counted in conflicted and listed in conflicts, and a job a conflict stopped fails without being retried (an outage is still retried)
  • Fixed: Every report on GET /api/v1/reports/ and /api/v2/reports/ answered 500 under strict types; they answer again, a row with no clicks or no cost no longer fails one, and the total ROI is a percentage (a -50% total read -1%). The v1 WordPress feed lists each landing page under its own account's campaign, the text-ad reports' "[no text ad]" row counts every click grouped there, and v1's landing-page report no longer answers a database error
  • Fixed: A subscription whose provider id holds a space, '/' or '?' could be stored but never sent events: POST /ltv/subscriptions/{ref}/events decodes the segment, and p202 ltv subscription event escapes it
  • Fixed: A landing page created through the API had no public id, so landing.php and the go/lp redirects (lpip=) could never track it, and a campaign's was a random 8-digit number with no uniqueness check, which go.php resolves across every account. Both get the Setup pages' form (a random digit, the row id, a random digit), unique by construction and returned read-only, and listing or getting landing pages or campaigns gives the account's id-less ones (NULL or 0) one. p202 campaign get and landing-page get take the public id too, and say on stderr when they resolved one
  • Fixed: A Slack notice sent by the cron job (a scheduled click deletion finishing) printed an undefined SERVER_NAME warning and went out with no username; it names the install by its tracking domain's host, or Prosper202
  • Fixed: The upgrade and requirements pages asked MySQL for every partition of every table on the server (7.1 s with 53 databases, on every load of the upgrade page); they ask about this database only
  • Fixed: p202 import flattened a record's API error, so a 401 exited 1 with a validation hint and a refused field lost its hint and field_errors; it keeps the category, exit code and hint, and says how many records were imported before it
  • Fixed: --help showed a backticked command in a flag's usage as the flag's value (--staged p202 change, 38 flags in all); in p202 shell a repeatable flag kept the previous command's values and a literal "[]"; report crosstab printed a partial table when a row's breakdown failed and now fails naming the row; forecast refuses a history the server truncated instead of fitting the cut series
  • Fixed: The PHP CLI (bin/p202) names itself the legacy PHP CLI and points at the Go CLI in --version, list and its bare run, and its help and checks take their lists from the server (report:breakdown --sort lacked three sorts, ltv:predict --by lacked product). Both CLIs share ~/.p202/config.json, and after any Go CLI write the PHP CLI was unconfigured; it reads and writes the active profile's url and api_key, and config:show prints them with the key masked (it printed profiles: Array, and short keys in full)
  • Fixed: The installer, the requirements page and the upgrade page let MariaDB 10.1 to 10.5 through. PHP reports MariaDB's version without the "5.5.5-" prefix the check looked for, so it was read as MySQL and passed as newer than 8.0, and the install or upgrade then stopped partway at the first JSON column. MariaDB is now recognised by name, the three pages share one check, and a server below MariaDB 10.6 or MySQL 8.0, or one whose version can't be read, is refused before anything changes