<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FlareCanary</title>
    <description>The latest articles on DEV Community by FlareCanary (@flarecanary).</description>
    <link>https://dev.to/flarecanary</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3834499%2F8c191c74-2040-4cd1-beaa-4ca99b664ca9.png</url>
      <title>DEV Community: FlareCanary</title>
      <link>https://dev.to/flarecanary</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/flarecanary"/>
    <language>en</language>
    <item>
      <title>Contentful's August 14 CMA update silently truncates your role lists and strips fields from user responses</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 25 Jul 2026 05:00:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/contentfuls-august-14-cma-update-silently-truncates-your-role-lists-and-strips-fields-from-user-2567</link>
      <guid>https://dev.to/flarecanary/contentfuls-august-14-cma-update-silently-truncates-your-role-lists-and-strips-fields-from-user-2567</guid>
      <description>&lt;p&gt;If you manage Contentful spaces programmatically — a script that syncs roles into your IdP, a member-roster export, an onboarding job that provisions access, anything that hits the &lt;strong&gt;Content Management API&lt;/strong&gt; to enumerate roles or users — two changes land on &lt;strong&gt;August 14, 2026&lt;/strong&gt; that fail in the worst way: they return a 200, valid JSON, and less data than they did the day before.&lt;/p&gt;

&lt;p&gt;Both are listed on Contentful's own &lt;a href="https://www.contentful.com/developers/api-changes/" rel="noopener noreferrer"&gt;API changes page&lt;/a&gt;. Neither throws an error. Both depend on a precondition you might not be checking — the shape of the pagination block, or the role of the token doing the reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 1: the roles endpoint swaps offset pagination for cursors
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;GET /spaces/{spaceId}/roles&lt;/code&gt;. Today it paginates the way most Contentful collection endpoints do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of August 14 it switches to cursor-based pagination. &lt;code&gt;total&lt;/code&gt; and &lt;code&gt;skip&lt;/code&gt; go away; you get a &lt;code&gt;pages&lt;/code&gt; object with &lt;code&gt;next&lt;/code&gt;/&lt;code&gt;previous&lt;/code&gt; cursor links instead, and &lt;code&gt;limit&lt;/code&gt; stays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;cursor&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"previous"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The migration is documented and the fix is "use &lt;code&gt;pageNext&lt;/code&gt;/&lt;code&gt;pagePrev&lt;/code&gt; cursor parameters instead of &lt;code&gt;skip&lt;/code&gt;." The problem is what happens to code that &lt;em&gt;doesn't&lt;/em&gt; migrate, because none of it errors out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three ways this silently truncates
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. The &lt;code&gt;total&lt;/code&gt;-driven loop exits after page one.&lt;/strong&gt; This is the canonical offset-pagination idiom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRoles&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// res.total is now undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After August 14, &lt;code&gt;res.total&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. &lt;code&gt;skip &amp;lt; undefined&lt;/code&gt; evaluates to &lt;code&gt;false&lt;/code&gt; on the first iteration, so the loop runs exactly once and stops. A space with 60 roles syncs the first 25 and silently drops 35 — no exception, no empty result you'd notice, just a short list that looks plausible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The "short page means last page" heuristic drops the tail.&lt;/strong&gt; Code that decides it's done when &lt;code&gt;items.length &amp;lt; limit&lt;/code&gt; is making an assumption cursor pagination doesn't honor — a non-final cursor page is not guaranteed to be full. Stop on the first short page and you lose every role after it. (This is the same class of bug teams hit with cursor migrations on Jira, monday.com, and Greenhouse — a server can return a half-full page with a live &lt;code&gt;next&lt;/code&gt; cursor.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Persisted numeric offsets have nothing to persist.&lt;/strong&gt; A cron job that stores "I got through &lt;code&gt;skip=50&lt;/code&gt;, resume there next run" breaks completely: cursors are opaque tokens scoped to a live pagination session, not a stable integer offset you can stash in a database and reuse tomorrow. The stored offset is meaningless and the resume logic either errors or restarts from the top.&lt;/p&gt;

&lt;p&gt;What runs &lt;code&gt;GET /roles&lt;/code&gt;? Access-provisioning automation that maps Contentful roles to an IdP, compliance exports that enumerate who-can-do-what, and admin dashboards. A truncated role list means access reviews that miss roles, and provisioning logic that can't find a role it's supposed to assign.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 2: user-data responses become role-based
&lt;/h2&gt;

&lt;p&gt;The second change hits "Get all users in a space" and "Get all Space Members in a space." Contentful's exact wording:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;API responses for user data will become role-based: Admin users will continue to receive full responses, with no changes. Non-admin users will receive limited user information.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the &lt;em&gt;same request, same endpoint, same 200&lt;/em&gt; returns a fuller or thinner user object depending on the role of the &lt;strong&gt;token&lt;/strong&gt; making the call. Admin token: unchanged. Non-admin token: trimmed payload.&lt;/p&gt;

&lt;p&gt;This is a nastier silent surface than the pagination one, because it's environment-dependent in a way that defeats casual testing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It works in dev and under-fetches in prod.&lt;/strong&gt; You build and test the integration with your own admin Personal Access Token — full responses, everything's there. It ships to prod running under a scoped, non-admin service token, and after August 14 the fields you depend on quietly stop coming back. Classic "works on my machine," except the machine is the token's role and nothing in your code changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. User-sync and audit exports write nulls.&lt;/strong&gt; A job that reads &lt;code&gt;email&lt;/code&gt; (or any of the fields that get trimmed) off the users list with a non-admin token now gets &lt;code&gt;undefined&lt;/code&gt; for them. Downstream you get user records synced with blank emails, SCIM-ish reconciliation that can't match people, audit CSVs with empty columns — all populated from a 200 response that looks successful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Member dashboards render partial detail with nothing to alert on.&lt;/strong&gt; A 200 with a smaller object doesn't trip error monitoring. The dashboard just shows less, and unless someone eyeballs it against yesterday, the regression is invisible.&lt;/p&gt;

&lt;p&gt;The fix is to check, before August 14, which token role your integration uses against these endpoints. If it's non-admin, confirm exactly which user fields you read, and either elevate the token to admin (if the use case justifies it) or stop depending on the fields that get trimmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Any pagination loop against &lt;code&gt;/spaces/{spaceId}/roles&lt;/code&gt; that reads &lt;code&gt;.total&lt;/code&gt; or increments &lt;code&gt;.skip&lt;/code&gt; — those break on August 14. Migrate to reading &lt;code&gt;pages.next&lt;/code&gt; and stopping when it's absent.&lt;/li&gt;
&lt;li&gt;Any "done when &lt;code&gt;items.length &amp;lt; limit&lt;/code&gt;" heuristic on a Contentful collection — replace with "done when there's no next cursor."&lt;/li&gt;
&lt;li&gt;Any persisted numeric offset for roles pagination — it can't survive the cursor migration.&lt;/li&gt;
&lt;li&gt;Any call to the space-users or space-members endpoints, plus the &lt;strong&gt;role of the token&lt;/strong&gt; that makes it. Non-admin tokens reading user fields are the ones that silently lose data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tell for both changes is identical and easy to miss: a &lt;code&gt;200 OK&lt;/code&gt; that returns less than it did yesterday. There's no status code, no error body, and no changelog entry that fires inside your monitoring — the response is structurally valid and semantically wrong.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Schema drift like this — a field that stops populating, a pagination block that changes shape, a payload that thins out under a scoped token — is exactly the failure mode &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; was built to catch: it watches your real API responses and tells you when their shape changes, before the silently-wrong data reaches your database.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>integrations</category>
      <category>webdev</category>
      <category>cms</category>
    </item>
    <item>
      <title>monday.com's API now returns 200 users instead of your whole account — the 2026-07 version flip that fails silently</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 22 Jul 2026 05:00:38 +0000</pubDate>
      <link>https://dev.to/flarecanary/mondaycoms-api-now-returns-200-users-instead-of-your-whole-account-the-2026-07-version-flip-5c49</link>
      <guid>https://dev.to/flarecanary/mondaycoms-api-now-returns-200-users-instead-of-your-whole-account-the-2026-07-version-flip-5c49</guid>
      <description>&lt;p&gt;If you sync users out of monday.com — an SSO deprovisioning job, an HR-to-monday roster sync, a SCIM-ish reconciliation script, a dashboard that counts seats, anything that runs &lt;code&gt;query { users { id email } }&lt;/code&gt; — there's a change that went live on &lt;strong&gt;July 1, 2026&lt;/strong&gt; and fails in the worst way: it returns a 200, valid JSON, and the wrong number of users.&lt;/p&gt;

&lt;p&gt;monday.com versions its API by date. Version &lt;code&gt;2026-07&lt;/code&gt; entered release-candidate on April 1, 2026 and became the &lt;strong&gt;Current&lt;/strong&gt; (default stable) version on &lt;strong&gt;July 1, 2026&lt;/strong&gt;. The detail that makes this silent is monday's own documented default:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you don't pass an &lt;code&gt;API-Version&lt;/code&gt; header, your app will always get the &lt;strong&gt;Current&lt;/strong&gt; version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So every integration that doesn't pin an explicit version — and a lot of them don't, because the SDKs make it optional — rolled onto &lt;code&gt;2026-07&lt;/code&gt; on July 1 with &lt;strong&gt;no deploy, no changelog read, no error&lt;/strong&gt;. Integrations pinned to an older version aren't safe either: when monday deprecates a version, requests to it &lt;a href="https://developer.monday.com/api-reference/docs/api-versioning" rel="noopener noreferrer"&gt;fall forward to the Maintenance version&lt;/a&gt; (that's exactly what the February 15, 2026 deprecation did — it routed &lt;code&gt;2025-01&lt;/code&gt; and &lt;code&gt;2024-10&lt;/code&gt; to &lt;code&gt;2025-04&lt;/code&gt;). Either path lands you on the new &lt;code&gt;Query.users&lt;/code&gt; behavior eventually.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;2026-07&lt;/code&gt; release is a full overhaul of the User entity. Most of it is documented as a breaking change. The parts that bite are the ones that &lt;em&gt;don't&lt;/em&gt; break loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in &lt;code&gt;Query.users&lt;/code&gt;
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Before &lt;code&gt;2026-07&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;2026-07&lt;/code&gt; (Current since Jul 1)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;limit&lt;/code&gt; argument&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;all&lt;/strong&gt; matching users returned&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;200&lt;/strong&gt; users returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum &lt;code&gt;limit&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unbounded&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1000&lt;/strong&gt; (over that → error)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Date&lt;/code&gt; (&lt;code&gt;2024-01-15&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ISO8601DateTime!&lt;/code&gt; (&lt;code&gt;2024-01-15T09:30:00Z&lt;/code&gt;, non-null)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;birthday&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;String&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;utc_hours_diff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Int&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Float&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;emails&lt;/code&gt; argument&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[String]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;[String!]&lt;/code&gt; (nulls in the array now rejected)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;is_admin&lt;/code&gt;, &lt;code&gt;is_guest&lt;/code&gt;, &lt;code&gt;is_view_only&lt;/code&gt;, &lt;code&gt;is_pending&lt;/code&gt;, &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;is_verified&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;deprecated&lt;/strong&gt; (removed in &lt;code&gt;2026-10&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kind&lt;/code&gt;, &lt;code&gt;newest_first&lt;/code&gt;, &lt;code&gt;non_active&lt;/code&gt; arguments&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;deprecated&lt;/strong&gt; → &lt;code&gt;user_kind&lt;/code&gt;, &lt;code&gt;sort&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt; (removed in &lt;code&gt;2026-10&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline is the first row. The rest stack on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;Query.users&lt;/code&gt; without a &lt;code&gt;limit&lt;/code&gt; silently caps at 200
&lt;/h2&gt;

&lt;p&gt;This is the query that's in everyone's codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before &lt;code&gt;2026-07&lt;/code&gt;, that returned every user in the account. As of July 1 it returns the first &lt;strong&gt;200&lt;/strong&gt; and stops — no &lt;code&gt;nextPageToken&lt;/code&gt;-style signal in the response telling you there's more, no error, no warning header. A 700-seat account syncs 200 users and silently drops 500.&lt;/p&gt;

&lt;p&gt;Think about what runs that query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSO / SCIM deprovisioning.&lt;/strong&gt; A nightly job pulls the monday user list, diffs it against the IdP, and disables anyone who left. After July 1 it only ever sees the first 200 users — so offboarded people in the tail of the list &lt;strong&gt;never get deprovisioned&lt;/strong&gt;. That's not a cosmetic bug; it's dangling access to a system of record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seat / license reconciliation.&lt;/strong&gt; Finance counts &lt;code&gt;users&lt;/code&gt; to true-up billing. The count quietly drops to 200 and the numbers look &lt;em&gt;better&lt;/em&gt;, so nobody investigates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HR roster sync.&lt;/strong&gt; New hires past the 200th user never appear downstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is explicit pagination — you now have to page with &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;page&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and loop until a page comes back with fewer than &lt;code&gt;limit&lt;/code&gt; rows. Note &lt;code&gt;limit&lt;/code&gt; itself is now capped at &lt;strong&gt;1000&lt;/strong&gt;; asking for more is one of the few things here that &lt;em&gt;does&lt;/em&gt; error, which is at least honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Three scalar type changes that parse fine and store wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;created_at&lt;/code&gt; went from a bare &lt;code&gt;Date&lt;/code&gt; to a non-null &lt;code&gt;ISO8601DateTime!&lt;/code&gt;. The field name didn't change, it's still a JSON string, the query still returns 200 — but the &lt;em&gt;value&lt;/em&gt; went from &lt;code&gt;2024-01-15&lt;/code&gt; to &lt;code&gt;2024-01-15T09:30:00Z&lt;/code&gt;. Anything that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;writes it into a &lt;code&gt;DATE&lt;/code&gt; column (now you're truncating or erroring on the time component, depending on the driver),&lt;/li&gt;
&lt;li&gt;does string equality against a stored &lt;code&gt;YYYY-MM-DD&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;or parses with a format string that doesn't expect a &lt;code&gt;T&lt;/code&gt;/&lt;code&gt;Z&lt;/code&gt;,&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;now silently mismatches. Date-range filters that compare &lt;code&gt;created_at &amp;gt;= '2024-01-15'&lt;/code&gt; as strings can flip results because &lt;code&gt;'2024-01-15T09:30:00Z' &amp;gt; '2024-01-15'&lt;/code&gt; lexically.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;utc_hours_diff&lt;/code&gt; went from &lt;code&gt;Int&lt;/code&gt; to &lt;code&gt;Float&lt;/code&gt;. The whole point is half-hour and 45-minute offsets — India is &lt;code&gt;5.5&lt;/code&gt;, Nepal &lt;code&gt;5.75&lt;/code&gt;. Code that did &lt;code&gt;parseInt&lt;/code&gt;, integer math, or unmarshalled into a Go &lt;code&gt;int&lt;/code&gt; / Java &lt;code&gt;int&lt;/code&gt; either drops the fraction silently or, in strict-typed clients, throws on the decimal. A "schedule this user's reminder in their timezone" feature is now off by 30 minutes for a chunk of the planet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;birthday&lt;/code&gt; went &lt;code&gt;Date&lt;/code&gt; → &lt;code&gt;String&lt;/code&gt;. Looser, but the same shape of trap: code expecting a parseable date object gets a free-form string.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The second shoe: &lt;code&gt;2026-10&lt;/code&gt; removes the fields you're still reading
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;2026-07&lt;/code&gt; the old User fields are &lt;strong&gt;deprecated but still work&lt;/strong&gt;. That's the trap — your code keeps reading them, your tests stay green, and the migration looks done. On the &lt;code&gt;2026-10&lt;/code&gt; version they're removed outright:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission booleans&lt;/strong&gt; &lt;code&gt;is_admin&lt;/code&gt;, &lt;code&gt;is_guest&lt;/code&gt;, &lt;code&gt;is_view_only&lt;/code&gt;, &lt;code&gt;is_pending&lt;/code&gt;, &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;is_verified&lt;/code&gt; → replaced by a single &lt;code&gt;status&lt;/code&gt; (a &lt;code&gt;UserStatus&lt;/code&gt; enum) plus &lt;code&gt;user_kind&lt;/code&gt;. A access-control check like &lt;code&gt;if (user.is_admin)&lt;/code&gt; either starts erroring on an unknown field (loud, if your client validates the schema) or — with permissive clients and fragment spreads — reads &lt;code&gt;null&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt; and &lt;strong&gt;treats an admin as a non-admin&lt;/strong&gt;, or vice-versa. Either direction is a security-relevant silent failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo fields&lt;/strong&gt; &lt;code&gt;photo_original&lt;/code&gt;, &lt;code&gt;photo_thumb&lt;/code&gt;, &lt;code&gt;photo_thumb_small&lt;/code&gt;, &lt;code&gt;photo_tiny&lt;/code&gt;, &lt;code&gt;photo_small&lt;/code&gt; → replaced by a nested &lt;code&gt;photo_url { original thumb tiny ... }&lt;/code&gt; object. Avatars silently 404 / go blank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Argument renames&lt;/strong&gt; &lt;code&gt;kind&lt;/code&gt; → &lt;code&gt;user_kind&lt;/code&gt;, &lt;code&gt;newest_first&lt;/code&gt; → &lt;code&gt;sort&lt;/code&gt;, &lt;code&gt;non_active&lt;/code&gt; → &lt;code&gt;status&lt;/code&gt;. A query filtering &lt;code&gt;users(non_active: true)&lt;/code&gt; to find deactivated accounts keeps working through the &lt;code&gt;2026-07&lt;/code&gt; window, then loses the argument in &lt;code&gt;2026-10&lt;/code&gt; — and depending on how your client handles an unknown argument, you either error or &lt;strong&gt;silently get the unfiltered set&lt;/strong&gt; (active users where you expected inactive).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only fix the 200-cap and stop, you've bought three months. The field removals are the same migration; do them now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# monday user queries with no limit (the 200-cap)&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEi&lt;/span&gt; &lt;span class="s1"&gt;'users\s*(\([^)]*\))?\s*\{'&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vi&lt;/span&gt; &lt;span class="s1"&gt;'limit'&lt;/span&gt;

&lt;span class="c"&gt;# Deprecated permission booleans removed in 2026-10&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'is_admin|is_guest|is_view_only|is_pending|is_verified|\.enabled'&lt;/span&gt; src/

&lt;span class="c"&gt;# Deprecated photo fields -&amp;gt; photo_url { ... }&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'photo_(original|thumb|thumb_small|tiny|small)'&lt;/span&gt; src/

&lt;span class="c"&gt;# Deprecated query arguments&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEw&lt;/span&gt; &lt;span class="s1"&gt;'kind|newest_first|non_active'&lt;/span&gt; src/

&lt;span class="c"&gt;# created_at parsed/stored as a bare date&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'created_at'&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'date|split|substr|strftime|DATE'&lt;/span&gt;

&lt;span class="c"&gt;# And the root cause: requests that never pin a version&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEi&lt;/span&gt; &lt;span class="s1"&gt;'api-version|monday.*version'&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-grep audit is the connector check: any Make, Zapier, n8n, Workato, or homegrown job hitting &lt;code&gt;api.monday.com/v2&lt;/code&gt; that doesn't set an explicit &lt;code&gt;API-Version&lt;/code&gt; header is, by monday's own rule, running on &lt;code&gt;2026-07&lt;/code&gt; right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this one lands silently
&lt;/h2&gt;

&lt;p&gt;Pin an explicit &lt;code&gt;API-Version&lt;/code&gt; header and none of this touches you until &lt;em&gt;you&lt;/em&gt; decide to move — that's the entire point of date-based versioning, and monday tells you to do it. But the default for an unversioned request isn't "stay where you are," it's "always get Current." So the integrations most exposed are the ones nobody's looked at in a year: the quiet nightly sync that's worked since 2024 and has no version header because it predates versioning.&lt;/p&gt;

&lt;p&gt;Every failure here returns &lt;strong&gt;200 OK with syntactically perfect, semantically incomplete&lt;/strong&gt; data. The user list parses. The count is a plausible number. The deprovisioning job exits zero. The healthcheck that asserts "last call was 2xx" stays green. The only loud signal — asking for &lt;code&gt;limit&lt;/code&gt; over 1000, or hitting a removed field on a strict client — is the &lt;em&gt;least&lt;/em&gt; likely path. The likely path is a roster that quietly got shorter on July 1 and a deprovisioning gap nobody will notice until an audit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary watches the response shapes of the APIs you depend on and tells you when a default limit changes, when a scalar like &lt;code&gt;created_at&lt;/code&gt; starts carrying a time component, or when a field you read every night quietly disappears. monday's &lt;code&gt;2026-07&lt;/code&gt; version flip is exactly the kind of 200-OK-but-wrong transition that slips past healthchecks and Sentry alike. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>integrations</category>
      <category>graphql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Jira's agile API drops offset pagination on November 1, 2026 — three ways your board and sprint syncs silently truncate</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 19 Jul 2026 05:00:48 +0000</pubDate>
      <link>https://dev.to/flarecanary/jiras-agile-api-drops-offset-pagination-on-november-1-2026-three-ways-your-board-and-sprint-1fjg</link>
      <guid>https://dev.to/flarecanary/jiras-agile-api-drops-offset-pagination-on-november-1-2026-three-ways-your-board-and-sprint-1fjg</guid>
      <description>&lt;p&gt;If you sync data out of Jira — a sprint-report dashboard, a Snowflake warehouse pulling issue history, a Slack bot that posts backlog counts, a custom burndown chart, anything reading the agile REST API — you have a pagination change coming that fails in the worst possible way: quietly.&lt;/p&gt;

&lt;p&gt;On &lt;strong&gt;November 1, 2026&lt;/strong&gt;, Jira Software Cloud removes &lt;strong&gt;random page access&lt;/strong&gt; from a set of nine agile endpoints. The &lt;a href="https://developer.atlassian.com/cloud/jira/software/changelog/" rel="noopener noreferrer"&gt;Jira Software Cloud changelog&lt;/a&gt; gives a six-month deprecation window and is explicit: the endpoints that page with &lt;code&gt;startAt&lt;/code&gt; will be removed, and integrations must move to &lt;strong&gt;token-based pagination&lt;/strong&gt; using &lt;code&gt;nextPageToken&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The endpoints in scope are the ones every agile integration leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /rest/agile/1.0/board/{boardId}/issue&lt;/code&gt; — issues for a board&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /rest/agile/1.0/board/{boardId}/backlog&lt;/code&gt; — issues in the backlog&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /rest/agile/1.0/board/{boardId}/sprint/{sprintId}/issue&lt;/code&gt; — issues in a sprint&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /rest/agile/1.0/board/{boardId}/epic/{epicId}/issue&lt;/code&gt; — issues under an epic&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /rest/agile/1.0/sprint/{sprintId}/issue&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;plus the board/epic/sprint &lt;em&gt;listing&lt;/em&gt; endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loud failure is the one teams plan around: after November 1 the deprecated &lt;code&gt;startAt&lt;/code&gt; path is gone, you get an error, you redeploy. The failures worth thinking about happen &lt;em&gt;before&lt;/em&gt; that — during the migration window, while both shapes work and the integration looks "done." Three of them swallow issues without raising anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed in the response
&lt;/h2&gt;

&lt;p&gt;Two things, and the second is the one that bites:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Old (offset)&lt;/th&gt;
&lt;th&gt;New (token)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page position&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;startAt&lt;/code&gt; query param&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nextPageToken&lt;/code&gt; query param&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-of-results signal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;startAt + maxResults &amp;gt;= total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;isLast: true&lt;/code&gt; / &lt;code&gt;nextPageToken&lt;/code&gt; absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;returned on every response&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no longer returned&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random access&lt;/td&gt;
&lt;td&gt;jump to any page; prefetch in parallel&lt;/td&gt;
&lt;td&gt;sequential only — you must walk the tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A response now looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issues"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nextPageToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CAEaAggB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isLast"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;startAt&lt;/code&gt;. No &lt;code&gt;total&lt;/code&gt;. You paginate by passing &lt;code&gt;nextPageToken&lt;/code&gt; back on the next request, and you stop when &lt;code&gt;isLast&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; (or &lt;code&gt;nextPageToken&lt;/code&gt; is absent). If you need a count, Atlassian points you at a separate approximate-count endpoint — the number is no longer free on the page you already fetched.&lt;/p&gt;

&lt;p&gt;Every one of the failure modes below comes from old code that assumed &lt;code&gt;total&lt;/code&gt; exists and that a short page means the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;total&lt;/code&gt; is gone, so &lt;code&gt;while (startAt &amp;lt; total)&lt;/code&gt; exits after page one
&lt;/h2&gt;

&lt;p&gt;The single most common offset loop in the wild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;start_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;start_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/rest/agile/1.0/board/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;board_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/issue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startAt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxResults&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issues&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- KeyError, or .get() -&amp;gt; None
&lt;/span&gt;    &lt;span class="n"&gt;start_at&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;total&lt;/code&gt; stops coming back, one of two things happens, and &lt;strong&gt;both fail open&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the code reads &lt;code&gt;resp["total"]&lt;/code&gt; directly, you get a &lt;code&gt;KeyError&lt;/code&gt; — at least that's loud. But most production clients learned long ago to use &lt;code&gt;resp.get("total")&lt;/code&gt; defensively.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;total = resp.get("total")&lt;/code&gt; → &lt;code&gt;None&lt;/code&gt;, the loop condition &lt;code&gt;start_at &amp;lt; None&lt;/code&gt; raises in Python 3, but the &lt;em&gt;very common&lt;/em&gt; guarded form &lt;code&gt;while total is None or start_at &amp;lt; total:&lt;/code&gt; evaluates &lt;code&gt;total is None&lt;/code&gt; as &lt;strong&gt;True forever&lt;/strong&gt; — so this one actually loops. The nastier variant is &lt;code&gt;while start_at &amp;lt; (total or 0):&lt;/code&gt;, which is &lt;code&gt;0 &amp;lt; 0&lt;/code&gt; → &lt;strong&gt;False after the first page&lt;/strong&gt;. One page of issues, loop exits clean, no error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last shape — &lt;code&gt;(total or 0)&lt;/code&gt;, &lt;code&gt;total ?? 0&lt;/code&gt;, &lt;code&gt;total || maxResults&lt;/code&gt; — is everywhere, because it's the idiom people reach for to "handle the case where total is missing." After November 1 (and on the new endpoints before then), it silently caps every sync at the first &lt;code&gt;maxResults&lt;/code&gt; issues. A board with 800 issues reports 50. The burndown chart still renders. The sprint report still has rows. Nobody alerts on "the dashboard has data, just less of it."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The "short page means done" heuristic drops the tail
&lt;/h2&gt;

&lt;p&gt;The other way teams avoid depending on &lt;code&gt;total&lt;/code&gt; is to stop when a page comes back shorter than &lt;code&gt;maxResults&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getIssues&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nextPageToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextPageToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- the bug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With offset pagination, a full page reliably meant "there's probably more," and a short page meant "you're at the end." &lt;strong&gt;Token pagination breaks that assumption.&lt;/strong&gt; Token-based pages are &lt;em&gt;not guaranteed to be full&lt;/em&gt; — the server can hand you 30 issues on a page with &lt;code&gt;isLast: false&lt;/code&gt; and a valid &lt;code&gt;nextPageToken&lt;/code&gt;, and the next page has the rest.&lt;/p&gt;

&lt;p&gt;This is the exact failure Atlassian integrators have been hitting on the platform's matching JQL migration: &lt;em&gt;any query returning more results than &lt;code&gt;maxResults&lt;/code&gt; silently drops results, with no indication that more exist.&lt;/em&gt; The loop above sees a 30-issue page, decides &lt;code&gt;30 !== 50&lt;/code&gt; means "done," throws away a perfectly good &lt;code&gt;nextPageToken&lt;/code&gt;, and returns a truncated set. The only correct end-of-results signal is &lt;code&gt;isLast === true&lt;/code&gt; or an absent &lt;code&gt;nextPageToken&lt;/code&gt; — &lt;strong&gt;never&lt;/strong&gt; the length of the page.&lt;/p&gt;

&lt;p&gt;If you have any pagination loop whose exit condition references &lt;code&gt;.length&lt;/code&gt;, &lt;code&gt;len(...)&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, or &lt;code&gt;=== maxResults&lt;/code&gt;, that's the first callsite to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Parallel prefetch and persisted offsets quietly stop working
&lt;/h2&gt;

&lt;p&gt;Two performance patterns that were safe with &lt;code&gt;startAt&lt;/code&gt; and aren't with tokens:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel page prefetch.&lt;/strong&gt; Dashboards that need a whole board fast often fan out requests — &lt;code&gt;startAt=0&lt;/code&gt;, &lt;code&gt;startAt=50&lt;/code&gt;, &lt;code&gt;startAt=100&lt;/code&gt; — concurrently, because with offsets you can compute every page boundary up front from &lt;code&gt;total&lt;/code&gt;. Token pagination is &lt;strong&gt;strictly sequential&lt;/strong&gt;: you can't know page N's token until you've fetched page N-1. A migration that keeps the parallel fan-out has nothing to fan out — it either collapses to fetching page one several times, or guesses offsets the new endpoint ignores. You get the first page, in triplicate, and call it the board.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persisted offset high-water marks.&lt;/strong&gt; Long-running cron syncs frequently store "I got up to &lt;code&gt;startAt=400&lt;/code&gt;, resume there tomorrow." There is no numeric offset to persist anymore, and the cursor token is opaque and not documented as stable across runs. A pipeline that resumes from a stored offset against the new endpoint resumes from nowhere — silently re-ingesting from the top or skipping the delta, depending on how the resume code degrades. The durable pattern is to re-paginate from the start each run and filter by &lt;code&gt;updated&lt;/code&gt; date, not to persist position.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for before November 1
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Agile endpoints that page by offset&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s1"&gt;'rest/agile/1\.0/(board|sprint).*(issue|backlog)'&lt;/span&gt; src/

&lt;span class="c"&gt;# Loops that depend on total&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;get&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]total|&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]total['&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;]&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="s2"&gt;|total&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\|\|\s&lt;/span&gt;&lt;span class="s2"&gt;*0|total&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\?\?\s&lt;/span&gt;&lt;span class="s2"&gt;*0"&lt;/span&gt; src/

&lt;span class="c"&gt;# End-of-results heuristics based on page length, not isLast&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s2"&gt;"length&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*===?&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="s2"&gt;*max|len&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;\)\s&lt;/span&gt;&lt;span class="s2"&gt;*&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*max|&amp;lt; maxResults"&lt;/span&gt; src/

&lt;span class="c"&gt;# Parallel offset fan-out&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s2"&gt;"startAt&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*[:=]&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*(i&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="s2"&gt;|page&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-grep audit is the connector check: any Fivetran, Airbyte, Workato, n8n, or homegrown ETL job that reads &lt;code&gt;/rest/agile/1.0/board/.../issue&lt;/code&gt; with &lt;code&gt;startAt&lt;/code&gt; needs to move to &lt;code&gt;nextPageToken&lt;/code&gt; and stop trusting &lt;code&gt;total&lt;/code&gt;. And anything that persists a numeric pagination offset between runs needs to switch to a date high-water mark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this one lands silently
&lt;/h2&gt;

&lt;p&gt;Every failure here returns &lt;strong&gt;200 OK with a syntactically perfect, semantically incomplete&lt;/strong&gt; response. The first page is real issues. The JSON parses. The dashboard renders. The healthcheck — which almost always asserts "did the last call return 2xx" — stays green. The only loud signal, the removed &lt;code&gt;startAt&lt;/code&gt; endpoint, doesn't fire until November 1, by which point the silently-truncated version may have been shipping wrong sprint metrics for weeks.&lt;/p&gt;

&lt;p&gt;Token pagination is the right call on Atlassian's part — offset paging over a moving issue set was never consistent. But "more correct" and "drop-in compatible" are different things, and the gap between them is exactly where a passing test suite and a clean error feed stop meaning what you think they mean.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary watches the response shapes of the APIs you depend on and tells you when a field like &lt;code&gt;total&lt;/code&gt; stops appearing, when a pagination contract changes, or when row counts shift in ways that look normal but aren't. Jira's agile pagination migration is precisely the kind of 200-OK-but-wrong transition that slips past healthchecks and Sentry alike. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>jira</category>
      <category>api</category>
      <category>integrations</category>
      <category>devops</category>
    </item>
    <item>
      <title>Google Ads API v21 sunsets in August — three silent ways the forced version bump corrupts your reports (still 200 OK)</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Thu, 16 Jul 2026 05:01:01 +0000</pubDate>
      <link>https://dev.to/flarecanary/google-ads-api-v21-sunsets-in-august-three-silent-ways-the-forced-version-bump-corrupts-your-p64</link>
      <guid>https://dev.to/flarecanary/google-ads-api-v21-sunsets-in-august-three-silent-ways-the-forced-version-bump-corrupts-your-p64</guid>
      <description>&lt;p&gt;If anything you run touches the Google Ads API — a bid-management tool, an agency reporting pipeline, a Looker Studio connector, a budget-pacing script, a custom dashboard pulling spend into a warehouse — you are on a clock you may not have noticed. Google moved the Google Ads API to a &lt;strong&gt;monthly release cycle in 2026&lt;/strong&gt;, and each major version now lives for roughly &lt;strong&gt;one year&lt;/strong&gt; after launch. Versions are sunsetting faster than they used to, and quietly.&lt;/p&gt;

&lt;p&gt;The dates that matter right now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v20 sunset on June 10, 2026.&lt;/strong&gt; Requests on v20 now fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v21 sunsets around August 6, 2026&lt;/strong&gt; — one year after its August 6, 2025 release. (&lt;a href="https://developers.google.com/google-ads/api/docs/sunset-dates" rel="noopener noreferrer"&gt;sunset schedule&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;v22 (Oct 15, 2025) and v23 (Jan 28, 2026) are the versions you're migrating &lt;em&gt;to&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;v21 is still one of the most widely pinned versions in production. If yours is one of them, you have until early August to move — and the version you land on (v23+) carries breaking changes from both v22 and v23 stacked on top of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loud failure is the one you'll plan for
&lt;/h2&gt;

&lt;p&gt;When a version sunsets, requests against it fail. You get an error, your monitoring lights up, you bump the version string in your client config and redeploy. Annoying, visible, fixable.&lt;/p&gt;

&lt;p&gt;That's not the failure mode that costs you a month of wrong numbers.&lt;/p&gt;

&lt;p&gt;The Google Ads API is queried through &lt;strong&gt;GAQL&lt;/strong&gt; and read back through generated client-library protos (&lt;code&gt;google-ads-python&lt;/code&gt;, &lt;code&gt;google-ads-php&lt;/code&gt;, the .NET and Java libraries). Both layers are forgiving in exactly the wrong way during a version bump: a query that used to return a value can keep returning &lt;strong&gt;200 OK with that value silently changed, blanked, or reclassified.&lt;/strong&gt; Three of those are worth grepping for before August.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A removed enum value makes your ad-type switch fall through
&lt;/h2&gt;

&lt;p&gt;v23 &lt;strong&gt;removed &lt;code&gt;VIDEO_OUTSTREAM&lt;/code&gt;&lt;/strong&gt; from three enums at once: &lt;code&gt;AdType&lt;/code&gt;, &lt;code&gt;AdvertisingChannelSubType&lt;/code&gt;, and &lt;code&gt;AdGroupType&lt;/code&gt; (&lt;a href="https://developers.google.com/google-ads/api/docs/release-notes" rel="noopener noreferrer"&gt;v23 release notes&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The field still exists. Your query still succeeds. But the value you were branching on never arrives anymore. Consider the canonical reporting shape — bucket spend by ad type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TYPE_LABELS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VIDEO_BUMPER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bumper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VIDEO_OUTSTREAM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Outstream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# v23: this enum value no longer exists
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VIDEO_TRUEVIEW_IN_STREAM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;In-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ga_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GAQL&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TYPE_LABELS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_group_ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_micros&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the bump, rows that used to come back as &lt;code&gt;VIDEO_OUTSTREAM&lt;/code&gt; come back as &lt;code&gt;UNKNOWN&lt;/code&gt; / &lt;code&gt;UNSPECIFIED&lt;/code&gt; (or are reshaped onto a different type). &lt;code&gt;TYPE_LABELS.get(...)&lt;/code&gt; doesn't raise — it returns &lt;code&gt;"Other"&lt;/code&gt;. Your outstream spend silently collapses into a catch-all bucket, or vanishes if you filter that bucket out. The query is valid, the response is 200, the totals at the campaign level still reconcile, and the only symptom is that one row in a breakdown table is wrong. Nobody alerts on that.&lt;/p&gt;

&lt;p&gt;The same trap applies anywhere you &lt;code&gt;WHERE&lt;/code&gt; or &lt;code&gt;GROUP BY&lt;/code&gt; one of these enums, and to the &lt;strong&gt;&lt;code&gt;if channel_sub_type == ...&lt;/code&gt;&lt;/strong&gt; branches that route campaigns to different handlers. A branch that no longer matches doesn't error — it falls through to the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Asset performance metrics come back empty, not missing
&lt;/h2&gt;

&lt;p&gt;v22 &lt;strong&gt;removed &lt;code&gt;AssetPerformanceLabel&lt;/code&gt; for Performance Max campaigns.&lt;/strong&gt; v23 went further: it &lt;strong&gt;removed aggregate asset performance-label metrics, and the performance-label enum is no longer returned for Search and Display&lt;/strong&gt; (&lt;a href="https://developers.google.com/google-ads/api/docs/release-notes" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Here's the asymmetry that makes this silent. If you &lt;code&gt;SELECT&lt;/code&gt; a field that the version has &lt;em&gt;deleted&lt;/em&gt;, GAQL throws a loud error and you find it in testing immediately. But these aren't deletes of the field path — they're cases where the field &lt;strong&gt;stops being populated&lt;/strong&gt;. The asset still has a &lt;code&gt;performance_label&lt;/code&gt;; for Search and Display it now returns the unspecified/empty value instead of &lt;code&gt;LOW&lt;/code&gt; / &lt;code&gt;GOOD&lt;/code&gt; / &lt;code&gt;BEST&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Query still valid, still 200. Field now empty for Search/Display assets.
&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
  SELECT asset.id, ad_group_ad_asset_view.performance_label, metrics.impressions
  FROM ad_group_ad_asset_view
  WHERE segments.date DURING LAST_30_DAYS
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ga_service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_group_ad_asset_view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;performance_label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;flag_for_replacement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# never fires again
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your "replace low-performing assets" automation stops finding anything to replace. Your asset-health dashboard shows every asset as unlabeled and reads it as "fine." There is no error, no empty result set, no 4xx — just a column that used to carry signal and now carries blanks. The same goes for the &lt;code&gt;Campaign.url_expansion_opt_out&lt;/code&gt; field &lt;strong&gt;removed in v22&lt;/strong&gt; (now governed by &lt;code&gt;AssetAutomationType.FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION&lt;/code&gt;): code that read the old boolean to decide whether to trust final-URL targeting silently reads a default and takes the wrong branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The renamed error field that breaks your error handler
&lt;/h2&gt;

&lt;p&gt;This one is small and perfect. v22 &lt;strong&gt;renamed &lt;code&gt;BudgetPerDayMinimumErrorDetails.minimum_bugdet_amount_micros&lt;/code&gt; to &lt;code&gt;minimum_budget_amount_micros&lt;/code&gt;&lt;/strong&gt; — Google fixed a typo in the field name (&lt;a href="https://developers.google.com/google-ads/api/docs/release-notes" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;If you wrote graceful budget-error handling — the kind that catches the API's "your daily budget is below the minimum" error and surfaces the actual minimum to the user — you almost certainly hard-coded the misspelled field name, because that's what the API gave you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;GoogleAdsException&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;budget_per_day_minimum_error_details&lt;/span&gt;
        &lt;span class="c1"&gt;# pre-v22 field name; returns proto default (0) after the bump
&lt;/span&gt;        &lt;span class="n"&gt;user_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minimum daily budget is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minimum_bugdet_amount_micros&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e6&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the bump, &lt;code&gt;minimum_bugdet_amount_micros&lt;/code&gt; no longer exists on the proto; accessing it returns the default &lt;code&gt;0&lt;/code&gt; (proto3 doesn't raise on unknown-as-default access in several client libraries). Your error handler now tells users the minimum budget is &lt;strong&gt;$0.00&lt;/strong&gt; — at exactly the moment they're hitting a budget error and need a real number. It fires only in the error path, which is the path with the thinnest test coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the Feeds removal is the loud one — but it relocated your extension data
&lt;/h2&gt;

&lt;p&gt;v23 &lt;strong&gt;removed all feed-related entities — &lt;code&gt;Feed&lt;/code&gt;, &lt;code&gt;FeedMapping&lt;/code&gt;, &lt;code&gt;FeedService&lt;/code&gt;, &lt;code&gt;AdGroupFeed&lt;/code&gt;, &lt;code&gt;feed_placeholder_view&lt;/code&gt;, and the rest.&lt;/strong&gt; Queries against those resources fail loudly, so you'll catch the direct breakage. The quieter follow-on: sitelinks, callouts, and structured snippets that used to live in Feeds now live in &lt;strong&gt;Assets&lt;/strong&gt;, with a different shape and different IDs. Reporting that aggregated extension performance by feed item has to be rebuilt against the asset model, and the rebuild is where double-counting and dropped extensions creep in. That's a migration project, not a one-line version bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for before August 6
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Direct version pins in client config / URLs&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'v2[0-3]|google-ads.*version|GOOGLE_ADS_API_VERSION'&lt;/span&gt; src/ config/

&lt;span class="c"&gt;# Enum values removed or reshaped in v22/v23&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'VIDEO_OUTSTREAM|AssetPerformanceLabel|url_expansion_opt_out'&lt;/span&gt; src/

&lt;span class="c"&gt;# The renamed error field&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s1"&gt;'minimum_bugdet_amount_micros'&lt;/span&gt; src/

&lt;span class="c"&gt;# Feed entities removed in v23&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'FeedMapping|AdGroupFeed|feed_placeholder_view|FeedService'&lt;/span&gt; src/

&lt;span class="c"&gt;# Any switch/dict keyed on ad type or channel sub-type — audit for fall-through defaults&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'ad_group_type|advertising_channel_sub_type|ad\.type_'&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-grep check is the persisted-config sweep: any Supermetrics, Funnel, Adverity, or homemade connector with a pinned &lt;code&gt;v20&lt;/code&gt;/&lt;code&gt;v21&lt;/code&gt; in its settings needs re-pointing, and every dashboard that buckets by ad type or reads an asset performance label needs a spot-check against a v23 response before the cutover — not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a forced upgrade fails silently
&lt;/h2&gt;

&lt;p&gt;The mental model for a version sunset is "it either works or it 503s." For the transport layer, that's true. For the &lt;em&gt;data&lt;/em&gt;, it isn't. GAQL keeps answering, the protos keep deserializing, and the values inside them quietly change contract: an enum loses a member, a metric stops populating, a field gets renamed out from under your accessor. Every one of those returns 200.&lt;/p&gt;

&lt;p&gt;The window between now and August 6 is when the wrong code ships, because both behaviors are observable at once: v21 still answers, v23 already answers, the tests pass, and the dashboards have numbers in them. The numbers are just bucketed wrong, blanked, or zeroed — and "the report has data" is not the same as "the report is right."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary watches the response shapes of the API endpoints you depend on and tells you when an enum value disappears, a metric goes empty, or a field gets renamed between versions — the drift that returns 200 and slips past a green healthcheck. A forced version sunset like Google Ads v21 → v23 is exactly the transition where passing tests don't mean what you think they mean. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleads</category>
      <category>api</category>
      <category>advertising</category>
      <category>devops</category>
    </item>
    <item>
      <title>Xero's Reference field stops returning invoice numbers on July 13 — and reconciliation won't notice</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 13 Jul 2026 05:01:07 +0000</pubDate>
      <link>https://dev.to/flarecanary/xeros-reference-field-stops-returning-invoice-numbers-on-july-13-and-reconciliation-wont-notice-2ig2</link>
      <guid>https://dev.to/flarecanary/xeros-reference-field-stops-returning-invoice-numbers-on-july-13-and-reconciliation-wont-notice-2ig2</guid>
      <description>&lt;p&gt;If you sync Xero — a bookkeeping integration, a payments reconciliation tool, an e-commerce-to-ledger bridge, or an in-house script that's been matching customer prepayments to invoices since forever — there's a one-line changelog entry with your name on it, and it doesn't throw an error.&lt;/p&gt;

&lt;p&gt;For as long as most integrators can remember, the &lt;code&gt;Reference&lt;/code&gt; field on Xero's &lt;strong&gt;Bank Transaction&lt;/strong&gt;, &lt;strong&gt;Prepayment&lt;/strong&gt;, and &lt;strong&gt;Payment&lt;/strong&gt; endpoints has returned the &lt;strong&gt;invoice number&lt;/strong&gt; for prepayment transactions. Not the reference &lt;em&gt;you&lt;/em&gt; typed on the transaction — the invoice number Xero generated. That was never quite what the field name promised, but it was stable, and a lot of code came to depend on it: "to find the invoice this prepayment belongs to, read &lt;code&gt;Reference&lt;/code&gt;." On &lt;strong&gt;July 13, 2026&lt;/strong&gt;, that stops being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes
&lt;/h2&gt;

&lt;p&gt;Xero is making the prepayment endpoints internally consistent with the rest of the API and with the web app. The rollout has two dates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;April 13, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A new explicit &lt;code&gt;InvoiceNumber&lt;/code&gt; field becomes available on these responses. It is the new source of truth for the RECEIVE-PREPAYMENT invoice number. &lt;code&gt;Reference&lt;/code&gt; is unchanged — still returns the invoice number.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;July 13, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Reference&lt;/code&gt; &lt;strong&gt;stops&lt;/strong&gt; returning the invoice number for RECEIVE-PREPAYMENTs and switches to returning the transaction's actual &lt;strong&gt;reference data&lt;/strong&gt; — the free-text reference, which is usually different and often empty.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So there's a three-month window where &lt;em&gt;both&lt;/em&gt; behaviours coexist: the new field is present, the old field hasn't flipped yet, and everything keeps working. That window is exactly what makes this dangerous. Nothing breaks in April. Nothing breaks in May or June. Code reviews pass. Then on July 13 the meaning of a field changes underneath running integrations that were "tested and working" a quarter earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a silent failure, not a loud one
&lt;/h2&gt;

&lt;p&gt;Every property of the response that a client checks stays the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP status:&lt;/strong&gt; still &lt;code&gt;200 OK&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON shape:&lt;/strong&gt; &lt;code&gt;Reference&lt;/code&gt; is still there, still a string. No field added or removed from the perspective of old code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type:&lt;/strong&gt; still a string. No deserialization error, no &lt;code&gt;null&lt;/code&gt; where an object was expected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The value:&lt;/strong&gt; silently changes from &lt;code&gt;"INV-0042"&lt;/code&gt; to whatever someone typed in the reference box — &lt;code&gt;"Deposit"&lt;/code&gt;, &lt;code&gt;"Stripe payout"&lt;/code&gt;, or &lt;code&gt;""&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A schema validator that checks "is &lt;code&gt;Reference&lt;/code&gt; a present string?" passes on both sides of July 13. The only thing that changed is the &lt;em&gt;semantics&lt;/em&gt; of the field — and semantics don't show up in a status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways this bites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Invoice matching silently misses
&lt;/h3&gt;

&lt;p&gt;The canonical prepayment-reconciliation loop reads &lt;code&gt;Reference&lt;/code&gt; to find the invoice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Match a RECEIVE-PREPAYMENT bank transaction back to its invoice&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoiceNo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// "INV-0042" … until July 13&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findInvoiceByNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceNo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;applyPrepayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before July 13, &lt;code&gt;txn.Reference&lt;/code&gt; is &lt;code&gt;"INV-0042"&lt;/code&gt; and the lookup hits. After July 13, &lt;code&gt;txn.Reference&lt;/code&gt; is the free-text reference — often empty, sometimes a memo like &lt;code&gt;"June deposit"&lt;/code&gt;. &lt;code&gt;findInvoiceByNumber("")&lt;/code&gt; returns nothing, &lt;code&gt;applyPrepayment&lt;/code&gt; never runs, and the prepayment sits &lt;strong&gt;unreconciled&lt;/strong&gt;. No exception, no log line, no failed request. Your books just quietly stop matching prepayments to invoices, and someone notices weeks later when the aged-receivables report doesn't foot.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The worse case: it matches the &lt;em&gt;wrong&lt;/em&gt; invoice
&lt;/h3&gt;

&lt;p&gt;Missing matches are the lucky outcome. The unlucky one is a collision. If a user happened to type something into the reference box that &lt;em&gt;looks&lt;/em&gt; like another invoice number — or your &lt;code&gt;findInvoiceByNumber&lt;/code&gt; does fuzzy/partial matching — the post-July-13 &lt;code&gt;Reference&lt;/code&gt; value can resolve to a &lt;strong&gt;different, real invoice&lt;/strong&gt;. Now a prepayment is applied against the wrong customer's invoice, two ledgers are wrong, and the only evidence is a number that matched something it shouldn't have. Silent-wrong is always more expensive than silent-missing, because you trust the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Filtering by reference changes what comes back
&lt;/h3&gt;

&lt;p&gt;The same change makes filtering "optimised" to operate on real reference data. Code that filters or searches bank transactions and prepayments by &lt;code&gt;Reference&lt;/code&gt; expecting to query on invoice numbers will, after July 13, be filtering on a different field's worth of data. Queries that returned the prepayment for &lt;code&gt;INV-0042&lt;/code&gt; yesterday return nothing today — or return a different set — and a paginated sync that keys off those results silently drops rows it used to pick up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is small — if you make it before July 13
&lt;/h2&gt;

&lt;p&gt;Xero gave you the escape hatch in April: the explicit &lt;code&gt;InvoiceNumber&lt;/code&gt; field. The migration is a one-line read change, and the safe window to do it is &lt;em&gt;now&lt;/em&gt;, while &lt;code&gt;Reference&lt;/code&gt; still agrees with &lt;code&gt;InvoiceNumber&lt;/code&gt; so you can verify the swap against live data before the flip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Source of truth for the invoice number is InvoiceNumber, not Reference&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoiceNo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InvoiceNumber&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// belt-and-suspenders during the window&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findInvoiceByNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceNo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to audit in every Xero integration you own:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for &lt;code&gt;Reference&lt;/code&gt; on prepayment/payment paths.&lt;/strong&gt; Anything that reads &lt;code&gt;.Reference&lt;/code&gt; off a Bank Transaction, Prepayment, or Payment response and treats it as an invoice number is on the clock. Search &lt;code&gt;grep -rn "\.Reference" .&lt;/code&gt; and triage every hit on these endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switch invoice-number reads to &lt;code&gt;InvoiceNumber&lt;/code&gt;.&lt;/strong&gt; It's available now (since April 13). Make the swap, deploy, and confirm it matches the current &lt;code&gt;Reference&lt;/code&gt; value &lt;em&gt;before&lt;/em&gt; July 13 — that's your free regression test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check anything that filters by reference.&lt;/strong&gt; If you query BankTransactions or Prepayments using &lt;code&gt;Reference&lt;/code&gt; to pull by invoice number, move that filter to the new field and re-validate the result set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume &lt;code&gt;Reference&lt;/code&gt; is now useless — assume it's now &lt;em&gt;correct&lt;/em&gt;.&lt;/strong&gt; After July 13 it carries the real user reference. If you have a separate need for that (display, search by user memo), this is an upgrade. The danger is only where the &lt;em&gt;old&lt;/em&gt; meaning was load-bearing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cutoff date here is loud in the changelog and silent in production. April's new field is the gift; July 13 is the cliff. The whole point of the three-month overlap is to let you migrate while you can still see both values side by side — which only helps if you know the flip is coming.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary monitors API responses for schema drift, silent field-semantics changes, and quiet removals across upstream providers. If you run integrations against Xero, Stripe, Shopify, PayPal, or anywhere else that ships breaking changes through a changelog post and a 200 OK, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt; catches the drift before your reconciliation does.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>accounting</category>
      <category>integrations</category>
      <category>fintech</category>
    </item>
    <item>
      <title>PayPal IPN is deprecated — the IPN Webhooks migration drops payments in four silent ways</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 11 Jul 2026 04:02:14 +0000</pubDate>
      <link>https://dev.to/flarecanary/paypal-ipn-is-deprecated-the-ipn-webhooks-migration-drops-payments-in-four-silent-ways-4jb9</link>
      <guid>https://dev.to/flarecanary/paypal-ipn-is-deprecated-the-ipn-webhooks-migration-drops-payments-in-four-silent-ways-4jb9</guid>
      <description>&lt;p&gt;If you take money through PayPal's legacy &lt;strong&gt;Instant Payment Notification (IPN)&lt;/strong&gt; — a Website Payments Standard button, a WooCommerce/Gravity Forms PayPal Standard gateway, or a hand-rolled &lt;code&gt;listener.php&lt;/code&gt; that's been quietly confirming orders since 2014 — the ground is already moving under you. PayPal stopped letting merchants generate new IPN/WPS credentials at the end of 2025, &lt;strong&gt;Website Payments Standard is deprecated as of January 2026&lt;/strong&gt;, and full end-of-life lands in &lt;strong&gt;January 2027&lt;/strong&gt;. The replacement is REST &lt;strong&gt;Webhooks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The deadline isn't the dangerous part. Jan 2027 is far enough that everyone plans for it. The dangerous part is the migration window we're in &lt;em&gt;right now&lt;/em&gt;, because IPN→Webhooks is not a rename. It changes four things at once, and each one has a failure mode that returns &lt;strong&gt;HTTP 200 to PayPal&lt;/strong&gt; — which makes PayPal stop retrying — while your system silently never records the payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  IPN and Webhooks disagree on almost everything
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;IPN (legacy)&lt;/th&gt;
&lt;th&gt;Webhooks (REST)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload shape&lt;/td&gt;
&lt;td&gt;flat key/value pairs&lt;/td&gt;
&lt;td&gt;nested &lt;code&gt;resource&lt;/code&gt; object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount field&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mc_gross&lt;/code&gt; = &lt;code&gt;"19.95"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resource.amount.value&lt;/code&gt; + &lt;code&gt;resource.amount.currency_code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status field&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payment_status&lt;/code&gt; = &lt;code&gt;"Completed"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;event_type&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;resource.status&lt;/code&gt; = &lt;code&gt;"COMPLETED"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer email&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payer_email&lt;/code&gt; (top level)&lt;/td&gt;
&lt;td&gt;&lt;em&gt;no equivalent in the capture resource&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom ref&lt;/td&gt;
&lt;td&gt;&lt;code&gt;custom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;custom_id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;txn_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resource.id&lt;/code&gt; (capture id) / &lt;code&gt;id&lt;/code&gt; (event id &lt;code&gt;WH-…&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;POST back &lt;code&gt;cmd=_notify-validate&lt;/code&gt;, expect &lt;code&gt;VERIFIED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;RSA-SHA256 over raw body + &lt;code&gt;PAYPAL-TRANSMISSION-*&lt;/code&gt; headers + cert chain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A migration that treats this as "point the same handler at the new payload" is broken on every row. Here are the four that fail &lt;em&gt;quietly&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The body parser still expects form-encoding — so every field is &lt;code&gt;undefined&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;IPN listeners are wired to read a form POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/paypal/ipn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencoded&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;extended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "Completed"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_gross&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// "19.95"&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fulfillOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point PayPal Webhooks at that endpoint without touching the parser and the JSON body lands on &lt;code&gt;express.urlencoded&lt;/code&gt;, which parses it into a single garbage key or an empty object. &lt;code&gt;req.body.payment_status&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the &lt;code&gt;=== 'Completed'&lt;/code&gt; check is false, &lt;code&gt;fulfillOrder&lt;/code&gt; never runs — and the handler still falls through to &lt;code&gt;res.sendStatus(200)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That 200 is the trap. PayPal treats 2xx as "delivered" and &lt;strong&gt;stops retrying&lt;/strong&gt;. The payment is captured, the buyer is charged, your webhook said OK, and the order is never fulfilled. Nothing in your logs is red. The only signal is a customer emailing "I paid and got nothing" days later — and by then the webhook is long past its retry window, so you can't even replay it without going to the PayPal dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Field paths moved, and the ones that didn't move &lt;em&gt;changed meaning&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Even after you switch to a JSON parser, a 1:1 field map fails because the data is nested and renamed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// IPN brain, JSON body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_gross&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// undefined → NaN downstream&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// undefined (it's custom_id now)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// undefined — and unrecoverable here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct paths are &lt;code&gt;req.body.resource.amount.value&lt;/code&gt; (a &lt;strong&gt;string&lt;/strong&gt;, not a number — &lt;code&gt;"19.95"&lt;/code&gt;), &lt;code&gt;req.body.resource.amount.currency_code&lt;/code&gt; (now a &lt;em&gt;separate&lt;/em&gt; field; IPN folded currency into &lt;code&gt;mc_currency&lt;/code&gt; alongside &lt;code&gt;mc_gross&lt;/code&gt;), and &lt;code&gt;req.body.resource.custom_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;payer_email&lt;/code&gt; is the sharp one. IPN handed you the buyer's email on every notification. The &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; resource &lt;strong&gt;does not contain the payer email at all&lt;/strong&gt; — payer identity lives on the &lt;em&gt;order&lt;/em&gt;, one API call up, not on the capture. Any code that keys a customer record, sends a receipt, or matches an account off &lt;code&gt;payer_email&lt;/code&gt; gets &lt;code&gt;undefined&lt;/code&gt; and either crashes (loud, lucky) or writes a blank/&lt;code&gt;null&lt;/code&gt; email into your users table (silent, unlucky). The fix is a follow-up &lt;code&gt;GET /v2/checkout/orders/{id}&lt;/code&gt; — an architectural change, not a field rename.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;amount&lt;/code&gt; being a &lt;em&gt;string&lt;/em&gt; means &lt;code&gt;mc_gross * quantity&lt;/code&gt; or any arithmetic that used to coerce cleanly now produces &lt;code&gt;NaN&lt;/code&gt; or string concatenation. &lt;code&gt;"19.95" &amp;lt; 20.00&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; by coercion but &lt;code&gt;"100.00" &amp;lt; 20.00&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; — comparisons mostly work until the one order where they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Status vocabulary: &lt;code&gt;"Completed"&lt;/code&gt; → &lt;code&gt;"COMPLETED"&lt;/code&gt;, in two places
&lt;/h2&gt;

&lt;p&gt;IPN had one status field with title-case values: &lt;code&gt;payment_status = "Completed" | "Pending" | "Refunded" | "Denied"&lt;/code&gt;. Webhooks split that into two layers, both &lt;strong&gt;upper-case&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event_type&lt;/code&gt; — &lt;code&gt;"PAYMENT.CAPTURE.COMPLETED"&lt;/code&gt;, &lt;code&gt;"PAYMENT.CAPTURE.REFUNDED"&lt;/code&gt;, &lt;code&gt;"PAYMENT.CAPTURE.DENIED"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource.status&lt;/code&gt; — &lt;code&gt;"COMPLETED"&lt;/code&gt;, &lt;code&gt;"PENDING"&lt;/code&gt;, &lt;code&gt;"DECLINED"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code carried over verbatim — &lt;code&gt;if (status === 'Completed')&lt;/code&gt; — silently never matches &lt;code&gt;"COMPLETED"&lt;/code&gt;. No error; the branch just never fires, so paid orders sit forever in "awaiting payment." The case flip is the kind of thing that passes a quick eyeball review (&lt;code&gt;Completed&lt;/code&gt; and &lt;code&gt;COMPLETED&lt;/code&gt; read the same when you're skimming) and fails 100% of the time in production.&lt;/p&gt;

&lt;p&gt;The deeper trap is the &lt;em&gt;vocabulary split&lt;/em&gt;. A refund used to arrive as &lt;code&gt;payment_status = "Refunded"&lt;/code&gt; on the same field you were already reading. Now a refund is a &lt;strong&gt;different &lt;code&gt;event_type&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;PAYMENT.CAPTURE.REFUNDED&lt;/code&gt;) that your handler may not even be subscribed to. If you only registered &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt;, refunds and chargebacks simply never reach you — your books say paid, PayPal says refunded, and the drift is invisible until reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verification flips from a postback handshake to RSA-SHA256 — and skip-verify becomes an open wallet
&lt;/h2&gt;

&lt;p&gt;IPN verification was a callback: take the exact payload, POST it back to PayPal with &lt;code&gt;cmd=_notify-validate&lt;/code&gt;, and trust it only if PayPal replies &lt;code&gt;VERIFIED&lt;/code&gt;. Webhooks replace that entirely with offline RSA-SHA256: you reconstruct a signing string from the &lt;strong&gt;raw request body&lt;/strong&gt;, the &lt;code&gt;PAYPAL-TRANSMISSION-ID&lt;/code&gt;, &lt;code&gt;PAYPAL-TRANSMISSION-TIME&lt;/code&gt;, your webhook id, and a CRC32 of the body, then verify the &lt;code&gt;PAYPAL-TRANSMISSION-SIG&lt;/code&gt; against the public cert at &lt;code&gt;PAYPAL-CERT-URL&lt;/code&gt;. (Or you call PayPal's &lt;code&gt;/v1/notifications/verify-webhook-signature&lt;/code&gt; and check &lt;code&gt;verification_status === "SUCCESS"&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Two silent failure modes hide here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The old postback still "works" enough to fool you.&lt;/strong&gt; During deprecation the &lt;code&gt;_notify-validate&lt;/code&gt; endpoint hasn't vanished, but posting a JSON webhook body back to it doesn't return &lt;code&gt;VERIFIED&lt;/code&gt;. Teams that kept the postback path see every webhook fail verification → return 4xx → PayPal retries then gives up → payments lost, exactly as in #1, but now it &lt;em&gt;looks&lt;/em&gt; like a security check doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skip-verify fallback becomes an open endpoint.&lt;/strong&gt; The most dangerous pattern is the dev-convenience branch:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypal-transmission-sig&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// "must be a local test"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who learns your webhook URL can now POST a hand-written &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; with any &lt;code&gt;custom_id&lt;/code&gt; and &lt;code&gt;amount&lt;/code&gt; they like, omit the signature header, and walk straight through to &lt;code&gt;fulfillOrder&lt;/code&gt;. IPN's postback design made this attack awkward (PayPal had to confirm). Webhook verification done wrong makes it a &lt;code&gt;curl&lt;/code&gt; one-liner. This won't appear in any log as an error — the forged request returns 200 just like a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for in every PayPal integration you own
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"payment_status&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;mc_gross&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;payer_email&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;_notify-validate"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hit is IPN-era code that breaks on the JSON payload. Then specifically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parser:&lt;/strong&gt; anything reading &lt;code&gt;req.body.*&lt;/code&gt; on a PayPal route behind &lt;code&gt;urlencoded&lt;/code&gt; middleware — JSON webhooks need a JSON parser &lt;strong&gt;and&lt;/strong&gt; the raw body preserved for signature verification (&lt;code&gt;express.json({ verify: (req,_,buf) =&amp;gt; req.rawBody = buf })&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status checks:&lt;/strong&gt; every &lt;code&gt;=== 'Completed'&lt;/code&gt; / &lt;code&gt;'Refunded'&lt;/code&gt; / &lt;code&gt;'Pending'&lt;/code&gt; — flip to upper-case &lt;code&gt;resource.status&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; branch on &lt;code&gt;event_type&lt;/code&gt;. Confirm you're subscribed to refund/dispute events, not just &lt;code&gt;COMPLETED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amounts:&lt;/strong&gt; every &lt;code&gt;mc_gross&lt;/code&gt; → &lt;code&gt;resource.amount.value&lt;/code&gt;, and treat it as a &lt;strong&gt;string&lt;/strong&gt; — parse explicitly before arithmetic or comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buyer identity:&lt;/strong&gt; every &lt;code&gt;payer_email&lt;/code&gt; → there is no drop-in; add the &lt;code&gt;GET /v2/checkout/orders/{id}&lt;/code&gt; lookup or capture payer data at checkout instead of expecting it on the webhook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; kill any &lt;code&gt;if (!sig) skip&lt;/code&gt; branch, and make sure you're doing RSA-SHA256 over the raw body — not posting JSON back to &lt;code&gt;_notify-validate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency:&lt;/strong&gt; dedup keyed on &lt;code&gt;txn_id&lt;/code&gt; silently treats every webhook as new (the field is gone). Re-key on the event &lt;code&gt;id&lt;/code&gt; (&lt;code&gt;WH-…&lt;/code&gt;) or &lt;code&gt;resource.id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason this one is worth auditing &lt;em&gt;now&lt;/em&gt; rather than in late 2026 is that the failure isn't gated on the January 2027 cliff. The moment you flip a button or gateway from IPN to Webhooks — which merchants are doing all through 2026 — these modes are live, and the worst of them (silent 200, lost payment, open endpoint) produce no error anywhere in your stack. The cutoff is loud. The migration is not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary monitors API responses for schema drift, silent removals, and behavior changes across upstream providers. If you run integrations against PayPal, Stripe, Shopify, GitHub, or anywhere else that ships breaking changes through changelog posts and deprecation notices, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt; catches the drift before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>payments</category>
      <category>api</category>
      <category>integrations</category>
    </item>
    <item>
      <title>PayPal IPN is deprecated — the IPN Webhooks migration drops payments in four silent ways</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 10 Jul 2026 05:00:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/paypal-ipn-is-deprecated-the-ipn-webhooks-migration-drops-payments-in-four-silent-ways-1afp</link>
      <guid>https://dev.to/flarecanary/paypal-ipn-is-deprecated-the-ipn-webhooks-migration-drops-payments-in-four-silent-ways-1afp</guid>
      <description>&lt;p&gt;If you take money through PayPal's legacy &lt;strong&gt;Instant Payment Notification (IPN)&lt;/strong&gt; — a Website Payments Standard button, a WooCommerce/Gravity Forms PayPal Standard gateway, or a hand-rolled &lt;code&gt;listener.php&lt;/code&gt; that's been quietly confirming orders since 2014 — the ground is already moving under you. PayPal stopped letting merchants generate new IPN/WPS credentials at the end of 2025, &lt;strong&gt;Website Payments Standard is deprecated as of January 2026&lt;/strong&gt;, and full end-of-life lands in &lt;strong&gt;January 2027&lt;/strong&gt;. The replacement is REST &lt;strong&gt;Webhooks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The deadline isn't the dangerous part. Jan 2027 is far enough that everyone plans for it. The dangerous part is the migration window we're in &lt;em&gt;right now&lt;/em&gt;, because IPN→Webhooks is not a rename. It changes four things at once, and each one has a failure mode that returns &lt;strong&gt;HTTP 200 to PayPal&lt;/strong&gt; — which makes PayPal stop retrying — while your system silently never records the payment.&lt;/p&gt;

&lt;h2&gt;
  
  
  IPN and Webhooks disagree on almost everything
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;IPN (legacy)&lt;/th&gt;
&lt;th&gt;Webhooks (REST)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/json&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload shape&lt;/td&gt;
&lt;td&gt;flat key/value pairs&lt;/td&gt;
&lt;td&gt;nested &lt;code&gt;resource&lt;/code&gt; object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amount field&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mc_gross&lt;/code&gt; = &lt;code&gt;"19.95"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resource.amount.value&lt;/code&gt; + &lt;code&gt;resource.amount.currency_code&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status field&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payment_status&lt;/code&gt; = &lt;code&gt;"Completed"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;event_type&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;resource.status&lt;/code&gt; = &lt;code&gt;"COMPLETED"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer email&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payer_email&lt;/code&gt; (top level)&lt;/td&gt;
&lt;td&gt;&lt;em&gt;no equivalent in the capture resource&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom ref&lt;/td&gt;
&lt;td&gt;&lt;code&gt;custom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;custom_id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;txn_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resource.id&lt;/code&gt; (capture id) / &lt;code&gt;id&lt;/code&gt; (event id &lt;code&gt;WH-…&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;POST back &lt;code&gt;cmd=_notify-validate&lt;/code&gt;, expect &lt;code&gt;VERIFIED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;RSA-SHA256 over raw body + &lt;code&gt;PAYPAL-TRANSMISSION-*&lt;/code&gt; headers + cert chain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A migration that treats this as "point the same handler at the new payload" is broken on every row. Here are the four that fail &lt;em&gt;quietly&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The body parser still expects form-encoding — so every field is &lt;code&gt;undefined&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;IPN listeners are wired to read a form POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/paypal/ipn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencoded&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;extended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "Completed"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_gross&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// "19.95"&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fulfillOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point PayPal Webhooks at that endpoint without touching the parser and the JSON body lands on &lt;code&gt;express.urlencoded&lt;/code&gt;, which parses it into a single garbage key or an empty object. &lt;code&gt;req.body.payment_status&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, the &lt;code&gt;=== 'Completed'&lt;/code&gt; check is false, &lt;code&gt;fulfillOrder&lt;/code&gt; never runs — and the handler still falls through to &lt;code&gt;res.sendStatus(200)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That 200 is the trap. PayPal treats 2xx as "delivered" and &lt;strong&gt;stops retrying&lt;/strong&gt;. The payment is captured, the buyer is charged, your webhook said OK, and the order is never fulfilled. Nothing in your logs is red. The only signal is a customer emailing "I paid and got nothing" days later — and by then the webhook is long past its retry window, so you can't even replay it without going to the PayPal dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Field paths moved, and the ones that didn't move &lt;em&gt;changed meaning&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Even after you switch to a JSON parser, a 1:1 field map fails because the data is nested and renamed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// IPN brain, JSON body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_gross&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// undefined → NaN downstream&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// undefined (it's custom_id now)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payer_email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// undefined — and unrecoverable here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct paths are &lt;code&gt;req.body.resource.amount.value&lt;/code&gt; (a &lt;strong&gt;string&lt;/strong&gt;, not a number — &lt;code&gt;"19.95"&lt;/code&gt;), &lt;code&gt;req.body.resource.amount.currency_code&lt;/code&gt; (now a &lt;em&gt;separate&lt;/em&gt; field; IPN folded currency into &lt;code&gt;mc_currency&lt;/code&gt; alongside &lt;code&gt;mc_gross&lt;/code&gt;), and &lt;code&gt;req.body.resource.custom_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;payer_email&lt;/code&gt; is the sharp one. IPN handed you the buyer's email on every notification. The &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; resource &lt;strong&gt;does not contain the payer email at all&lt;/strong&gt; — payer identity lives on the &lt;em&gt;order&lt;/em&gt;, one API call up, not on the capture. Any code that keys a customer record, sends a receipt, or matches an account off &lt;code&gt;payer_email&lt;/code&gt; gets &lt;code&gt;undefined&lt;/code&gt; and either crashes (loud, lucky) or writes a blank/&lt;code&gt;null&lt;/code&gt; email into your users table (silent, unlucky). The fix is a follow-up &lt;code&gt;GET /v2/checkout/orders/{id}&lt;/code&gt; — an architectural change, not a field rename.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;amount&lt;/code&gt; being a &lt;em&gt;string&lt;/em&gt; means &lt;code&gt;mc_gross * quantity&lt;/code&gt; or any arithmetic that used to coerce cleanly now produces &lt;code&gt;NaN&lt;/code&gt; or string concatenation. &lt;code&gt;"19.95" &amp;lt; 20.00&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; by coercion but &lt;code&gt;"100.00" &amp;lt; 20.00&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; — comparisons mostly work until the one order where they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Status vocabulary: &lt;code&gt;"Completed"&lt;/code&gt; → &lt;code&gt;"COMPLETED"&lt;/code&gt;, in two places
&lt;/h2&gt;

&lt;p&gt;IPN had one status field with title-case values: &lt;code&gt;payment_status = "Completed" | "Pending" | "Refunded" | "Denied"&lt;/code&gt;. Webhooks split that into two layers, both &lt;strong&gt;upper-case&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;event_type&lt;/code&gt; — &lt;code&gt;"PAYMENT.CAPTURE.COMPLETED"&lt;/code&gt;, &lt;code&gt;"PAYMENT.CAPTURE.REFUNDED"&lt;/code&gt;, &lt;code&gt;"PAYMENT.CAPTURE.DENIED"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource.status&lt;/code&gt; — &lt;code&gt;"COMPLETED"&lt;/code&gt;, &lt;code&gt;"PENDING"&lt;/code&gt;, &lt;code&gt;"DECLINED"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code carried over verbatim — &lt;code&gt;if (status === 'Completed')&lt;/code&gt; — silently never matches &lt;code&gt;"COMPLETED"&lt;/code&gt;. No error; the branch just never fires, so paid orders sit forever in "awaiting payment." The case flip is the kind of thing that passes a quick eyeball review (&lt;code&gt;Completed&lt;/code&gt; and &lt;code&gt;COMPLETED&lt;/code&gt; read the same when you're skimming) and fails 100% of the time in production.&lt;/p&gt;

&lt;p&gt;The deeper trap is the &lt;em&gt;vocabulary split&lt;/em&gt;. A refund used to arrive as &lt;code&gt;payment_status = "Refunded"&lt;/code&gt; on the same field you were already reading. Now a refund is a &lt;strong&gt;different &lt;code&gt;event_type&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;PAYMENT.CAPTURE.REFUNDED&lt;/code&gt;) that your handler may not even be subscribed to. If you only registered &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt;, refunds and chargebacks simply never reach you — your books say paid, PayPal says refunded, and the drift is invisible until reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verification flips from a postback handshake to RSA-SHA256 — and skip-verify becomes an open wallet
&lt;/h2&gt;

&lt;p&gt;IPN verification was a callback: take the exact payload, POST it back to PayPal with &lt;code&gt;cmd=_notify-validate&lt;/code&gt;, and trust it only if PayPal replies &lt;code&gt;VERIFIED&lt;/code&gt;. Webhooks replace that entirely with offline RSA-SHA256: you reconstruct a signing string from the &lt;strong&gt;raw request body&lt;/strong&gt;, the &lt;code&gt;PAYPAL-TRANSMISSION-ID&lt;/code&gt;, &lt;code&gt;PAYPAL-TRANSMISSION-TIME&lt;/code&gt;, your webhook id, and a CRC32 of the body, then verify the &lt;code&gt;PAYPAL-TRANSMISSION-SIG&lt;/code&gt; against the public cert at &lt;code&gt;PAYPAL-CERT-URL&lt;/code&gt;. (Or you call PayPal's &lt;code&gt;/v1/notifications/verify-webhook-signature&lt;/code&gt; and check &lt;code&gt;verification_status === "SUCCESS"&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Two silent failure modes hide here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The old postback still "works" enough to fool you.&lt;/strong&gt; During deprecation the &lt;code&gt;_notify-validate&lt;/code&gt; endpoint hasn't vanished, but posting a JSON webhook body back to it doesn't return &lt;code&gt;VERIFIED&lt;/code&gt;. Teams that kept the postback path see every webhook fail verification → return 4xx → PayPal retries then gives up → payments lost, exactly as in #1, but now it &lt;em&gt;looks&lt;/em&gt; like a security check doing its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skip-verify fallback becomes an open endpoint.&lt;/strong&gt; The most dangerous pattern is the dev-convenience branch:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paypal-transmission-sig&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// "must be a local test"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone who learns your webhook URL can now POST a hand-written &lt;code&gt;PAYMENT.CAPTURE.COMPLETED&lt;/code&gt; with any &lt;code&gt;custom_id&lt;/code&gt; and &lt;code&gt;amount&lt;/code&gt; they like, omit the signature header, and walk straight through to &lt;code&gt;fulfillOrder&lt;/code&gt;. IPN's postback design made this attack awkward (PayPal had to confirm). Webhook verification done wrong makes it a &lt;code&gt;curl&lt;/code&gt; one-liner. This won't appear in any log as an error — the forged request returns 200 just like a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for in every PayPal integration you own
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"payment_status&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;mc_gross&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;payer_email&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;_notify-validate"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hit is IPN-era code that breaks on the JSON payload. Then specifically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parser:&lt;/strong&gt; anything reading &lt;code&gt;req.body.*&lt;/code&gt; on a PayPal route behind &lt;code&gt;urlencoded&lt;/code&gt; middleware — JSON webhooks need a JSON parser &lt;strong&gt;and&lt;/strong&gt; the raw body preserved for signature verification (&lt;code&gt;express.json({ verify: (req,_,buf) =&amp;gt; req.rawBody = buf })&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status checks:&lt;/strong&gt; every &lt;code&gt;=== 'Completed'&lt;/code&gt; / &lt;code&gt;'Refunded'&lt;/code&gt; / &lt;code&gt;'Pending'&lt;/code&gt; — flip to upper-case &lt;code&gt;resource.status&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; branch on &lt;code&gt;event_type&lt;/code&gt;. Confirm you're subscribed to refund/dispute events, not just &lt;code&gt;COMPLETED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amounts:&lt;/strong&gt; every &lt;code&gt;mc_gross&lt;/code&gt; → &lt;code&gt;resource.amount.value&lt;/code&gt;, and treat it as a &lt;strong&gt;string&lt;/strong&gt; — parse explicitly before arithmetic or comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buyer identity:&lt;/strong&gt; every &lt;code&gt;payer_email&lt;/code&gt; → there is no drop-in; add the &lt;code&gt;GET /v2/checkout/orders/{id}&lt;/code&gt; lookup or capture payer data at checkout instead of expecting it on the webhook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; kill any &lt;code&gt;if (!sig) skip&lt;/code&gt; branch, and make sure you're doing RSA-SHA256 over the raw body — not posting JSON back to &lt;code&gt;_notify-validate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency:&lt;/strong&gt; dedup keyed on &lt;code&gt;txn_id&lt;/code&gt; silently treats every webhook as new (the field is gone). Re-key on the event &lt;code&gt;id&lt;/code&gt; (&lt;code&gt;WH-…&lt;/code&gt;) or &lt;code&gt;resource.id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason this one is worth auditing &lt;em&gt;now&lt;/em&gt; rather than in late 2026 is that the failure isn't gated on the January 2027 cliff. The moment you flip a button or gateway from IPN to Webhooks — which merchants are doing all through 2026 — these modes are live, and the worst of them (silent 200, lost payment, open endpoint) produce no error anywhere in your stack. The cutoff is loud. The migration is not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary monitors API responses for schema drift, silent removals, and behavior changes across upstream providers. If you run integrations against PayPal, Stripe, Shopify, GitHub, or anywhere else that ships breaking changes through changelog posts and deprecation notices, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt; catches the drift before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>payments</category>
      <category>api</category>
      <category>integrations</category>
    </item>
    <item>
      <title>OpenAI's Assistants API shuts down August 26 — but the silent failures hit weeks earlier, when you migrate</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 07 Jul 2026 05:01:15 +0000</pubDate>
      <link>https://dev.to/flarecanary/openais-assistants-api-shuts-down-august-26-but-the-silent-failures-hit-weeks-earlier-when-you-32d0</link>
      <guid>https://dev.to/flarecanary/openais-assistants-api-shuts-down-august-26-but-the-silent-failures-hit-weeks-earlier-when-you-32d0</guid>
      <description>&lt;p&gt;On &lt;strong&gt;August 26, 2026&lt;/strong&gt;, OpenAI removes the Assistants API. Calls to &lt;code&gt;/v1/assistants&lt;/code&gt;, &lt;code&gt;/v1/threads&lt;/code&gt;, and &lt;code&gt;/v1/threads/runs&lt;/code&gt; start returning errors. That date has been on developer calendars for a year, and it's the easy part: a hard cutoff fails loudly, in your face, on a day you can plan around.&lt;/p&gt;

&lt;p&gt;The part that actually costs people is the migration to the &lt;strong&gt;Responses API&lt;/strong&gt; — and that one doesn't wait for August 26, and it doesn't fail loudly.&lt;/p&gt;

&lt;p&gt;When you port an Assistants-based app to Responses, the endpoints answer with &lt;code&gt;200 OK&lt;/code&gt;. Nothing throws. But three things the Assistants API used to manage &lt;em&gt;for you&lt;/em&gt; are now your job, and if you wire any of them wrong, the failure shows up as &lt;strong&gt;degraded behavior, not an error&lt;/strong&gt;: a model that forgets context, grounding that quietly drops, a stream that renders blank. You ship it, it looks fine in a smoke test, and it's wrong in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the dangerous kind of breaking change
&lt;/h2&gt;

&lt;p&gt;The Assistants API was stateful and opinionated. A Thread persisted conversation history, truncated it to fit the context window, and orchestrated tool runs — implicitly, with no code on your side. The Responses API is deliberately simpler: you send input items, you get output items back. Almost everything the Thread did for you is now an explicit decision.&lt;/p&gt;

&lt;p&gt;That inversion is the trap. A "shallow" migration — swap the endpoint, keep the shape of your code — compiles, runs, and returns &lt;code&gt;200&lt;/code&gt;. The regressions are in the gap between &lt;em&gt;what the Thread used to do automatically&lt;/em&gt; and &lt;em&gt;what you now have to do on purpose&lt;/em&gt;. None of that gap raises an exception. It just makes the output worse, on a delay, in ways your tests probably don't assert on.&lt;/p&gt;

&lt;p&gt;Here are the three places it bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. State regression — follow-up turns silently lose context
&lt;/h2&gt;

&lt;p&gt;In the Assistants API, you appended a message to a Thread and the Thread &lt;em&gt;was&lt;/em&gt; the state. History and truncation were handled for you.&lt;/p&gt;

&lt;p&gt;In Responses, persistent conversation state is explicit. You either chain turns by passing &lt;code&gt;previous_response_id&lt;/code&gt; from the last response into the next request, or you use the &lt;strong&gt;Conversations API&lt;/strong&gt; to hold history. Two things go wrong here, both silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You forget to chain at all.&lt;/strong&gt; Each request becomes effectively stateless. The first turn looks perfect. The second turn — "and make it shorter" — has no idea what "it" is, because the prior turn's context and your earlier system constraints never traveled with it. The API returns a fluent, confident, context-free answer with a &lt;code&gt;200&lt;/code&gt;. There's no error to catch; the model just behaves like it has amnesia.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You mix both mechanisms.&lt;/strong&gt; OpenAI's guidance is explicit: pick one state model per workload — &lt;code&gt;previous_response_id&lt;/code&gt; &lt;em&gt;or&lt;/em&gt; Conversations — and don't interleave them ad hoc. Apps that half-adopt Conversations while still passing response IDs get inconsistent history depending on the path a request takes. Constraints set early in a session reappear and disappear between turns, and it reads like a flaky model rather than a state bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Assistants version of this code couldn't have this bug, because the Thread never let state fall on the floor. The Responses version can, and it won't tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Retrieval regression — RAG grounding drops with no error
&lt;/h2&gt;

&lt;p&gt;Assistants &lt;code&gt;file_search&lt;/code&gt; was forgiving: a run could search across both assistant-level and thread-level vector stores, and the run flow wired retrieval in for you. You attached files, asked a question, and got grounded answers.&lt;/p&gt;

&lt;p&gt;In Responses, file search is configured &lt;strong&gt;explicitly&lt;/strong&gt; on the tool — you pass the &lt;code&gt;vector_store_ids&lt;/code&gt; you want searched, and retrieval behavior is yours to tune. The failure mode is obvious in hindsight and invisible at runtime: &lt;strong&gt;miss a vector store in the list, and that knowledge silently disappears from grounding.&lt;/strong&gt; The request succeeds. The model still answers — it just answers from less context, or from none, and falls back on its parametric memory.&lt;/p&gt;

&lt;p&gt;A retrieval miss doesn't look like a &lt;code&gt;404&lt;/code&gt;. It looks like a slightly worse answer, or a confidently wrong one, for the subset of questions that depended on the store you dropped. Nobody gets paged. Your eval suite — if you have one — catches it. Your &lt;code&gt;200&lt;/code&gt;-checking integration test does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Streaming breakage — the SSE event taxonomy changed
&lt;/h2&gt;

&lt;p&gt;If you stream responses, this is the one most likely to ship broken to real users.&lt;/p&gt;

&lt;p&gt;The Assistants API emitted run-centric server-sent events — deltas tied to runs, run steps, and messages (&lt;code&gt;thread.run.step.delta&lt;/code&gt;, &lt;code&gt;thread.message.delta&lt;/code&gt;, and friends). Downstream consumers were written to pattern-match those event types and assemble the output from them.&lt;/p&gt;

&lt;p&gt;The Responses API emits a &lt;strong&gt;different event taxonomy&lt;/strong&gt; — response-centric events like &lt;code&gt;response.output_text.delta&lt;/code&gt; and &lt;code&gt;response.completed&lt;/code&gt;. A consumer still listening for the old &lt;code&gt;thread.*&lt;/code&gt; events does something worse than crash: it connects, the stream opens, events flow, the stream completes — and &lt;strong&gt;none of them match the handlers it's looking for.&lt;/strong&gt; The result is an empty or truncated render on a connection that, by every status check, succeeded. &lt;code&gt;200&lt;/code&gt;, clean SSE, blank UI.&lt;/p&gt;

&lt;p&gt;This is exactly the kind of break that passes review (the request works!) and gets discovered by a user, because the failure lives in event-name strings, not in HTTP status.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bonus that hits your bill, not your logs
&lt;/h2&gt;

&lt;p&gt;Built-in tools moved too. In Responses, hosted tools like web search and file search carry &lt;strong&gt;per-call fees&lt;/strong&gt; that didn't exist under the Assistants API. Migrate a high-traffic assistant that leaned on those tools and your functionality is identical, your error rate is zero, and your invoice drifts up with no code change to point at. Silent cost regression is still a regression.&lt;/p&gt;

&lt;p&gt;And there's no shortcut waiting to save you: OpenAI has stated it will &lt;strong&gt;not&lt;/strong&gt; ship an automated tool to migrate existing Threads into Conversations. The recommended path is to start new sessions on the new model and backfill old history yourself. Anyone counting on a one-click migration in August is going to find out, in August, that it isn't coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do before August 26
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat the migration as an architecture change, not an endpoint swap.&lt;/strong&gt; The three regressions above all come from assuming Responses will do what the Thread did implicitly. It won't. Map your state, retrieval, and streaming explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick one state model and assert on it.&lt;/strong&gt; &lt;code&gt;previous_response_id&lt;/code&gt; &lt;em&gt;or&lt;/em&gt; Conversations — not both. Add a test that runs a multi-turn conversation and checks that turn N actually respects a constraint set in turn 1. That single test catches the silent amnesia bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin your vector store IDs and eval grounding.&lt;/strong&gt; Diff the stores your old assistant could reach against the &lt;code&gt;vector_store_ids&lt;/code&gt; you pass in Responses. Then run a retrieval eval, not just a &lt;code&gt;200&lt;/code&gt; check — a request that returns successfully with empty grounding is the failure you're looking for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite your stream consumer for the new events and test it end to end.&lt;/strong&gt; Don't assume your SSE parser "still works" because the connection opens. Confirm it assembles real output from &lt;code&gt;response.*&lt;/code&gt; events, not &lt;code&gt;thread.*&lt;/code&gt; ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the response shape, not just the status code.&lt;/strong&gt; Every one of these failures returns &lt;code&gt;200&lt;/code&gt;. The only way to catch a state drop, a grounding miss, or an unhandled event is to inspect what's actually in the response — not whether the call succeeded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shutdown date is the loud, well-advertised part, and it's the part you're least likely to get wrong. The migration is the quiet part — and a &lt;code&gt;200&lt;/code&gt; that's missing your context, your grounding, or your output is far harder to notice than an honest error on August 26.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your API responses for exactly this: a field that changes type, a value that goes null, an event shape that drifts, a contract that quietly stops meaning what it used to. Migrations like Assistants → Responses are where response shapes change silently — and a &lt;code&gt;200&lt;/code&gt; with the wrong shape is the failure that reaches your users before it reaches your logs. If the contract you depend on changes, you hear it from us first.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openai</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Shopify retires API version 2025-07 on July 16 — pinned apps don't error, they silently fall forward to 2025-10</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 05 Jul 2026 04:01:32 +0000</pubDate>
      <link>https://dev.to/flarecanary/shopify-retires-api-version-2025-07-on-july-16-pinned-apps-dont-error-they-silently-fall-5bjl</link>
      <guid>https://dev.to/flarecanary/shopify-retires-api-version-2025-07-on-july-16-pinned-apps-dont-error-they-silently-fall-5bjl</guid>
      <description>&lt;p&gt;On &lt;strong&gt;July 16, 2026 at 15:00 UTC&lt;/strong&gt;, Shopify stops serving API version &lt;code&gt;2025-07&lt;/code&gt;. If your app, integration, or script still pins that version, here is the part that catches people off guard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your requests do not start failing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;400&lt;/code&gt;, no &lt;code&gt;406&lt;/code&gt;, no "unsupported version" error. Shopify's documented behavior is to &lt;em&gt;fall forward&lt;/em&gt;: a request for an inaccessible version is served using the &lt;strong&gt;oldest accessible stable version&lt;/strong&gt; instead. After July 16, that's &lt;code&gt;2025-10&lt;/code&gt;. So a call that says &lt;code&gt;X-Shopify-Api-Version: 2025-07&lt;/code&gt; quietly gets answered by &lt;code&gt;2025-10&lt;/code&gt; — a schema you never tested against, on a date you didn't pick.&lt;/p&gt;

&lt;p&gt;Same endpoint. Same &lt;code&gt;200 OK&lt;/code&gt;. Different response shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the dangerous kind of breaking change
&lt;/h2&gt;

&lt;p&gt;Most deprecation posts treat a version sunset like a model retirement: there's a date, calls stop working, you migrate before it. Loud, scheduled, hard to miss.&lt;/p&gt;

&lt;p&gt;Shopify version sunsets are the opposite. The whole point of fall-forward is that &lt;strong&gt;nothing breaks loudly&lt;/strong&gt; — Shopify keeps answering you so your store doesn't go dark. The cost of that graceful behavior is that the moment your pinned version disappears, your client silently starts consuming a newer contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fields deprecated-then-removed between &lt;code&gt;2025-07&lt;/code&gt; and &lt;code&gt;2025-10&lt;/code&gt; come back &lt;strong&gt;&lt;code&gt;null&lt;/code&gt;&lt;/strong&gt; or vanish from the payload.&lt;/li&gt;
&lt;li&gt;Types that &lt;em&gt;widened&lt;/em&gt; now return shapes your parsing code never expected.&lt;/li&gt;
&lt;li&gt;Enum values, default behaviors, and pagination semantics shift to the newer version's rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it raises an exception on Shopify's side. The break happens &lt;strong&gt;inside your code&lt;/strong&gt;, when it reads a field that moved and gets back something it wasn't written for. That's a margin report three weeks later, not a page at 3 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only signal is a response header
&lt;/h2&gt;

&lt;p&gt;Shopify does tell you which version actually served the request — in the &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; response header. After fall-forward, that header reads &lt;code&gt;2025-10&lt;/code&gt; even though you asked for &lt;code&gt;2025-07&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the catch: the signal is on &lt;strong&gt;every response&lt;/strong&gt;, but it's on the part of the response almost nobody inspects. Most clients read the JSON body and ignore the headers entirely. There is no Sunset header on the body, no error field, no deprecation block in the payload. If you're not diffing &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; against the version you sent, the fall-forward is invisible until behavior drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three concrete shifts a 2025-07 app inherits on July 16
&lt;/h2&gt;

&lt;p&gt;This isn't hypothetical. Here are real &lt;code&gt;2025-07&lt;/code&gt; → &lt;code&gt;2025-10&lt;/code&gt; Admin API changes your pinned client silently adopts the instant it falls forward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;StoreCreditAccount.owner&lt;/code&gt; widened from one type to a union.&lt;/strong&gt;&lt;br&gt;
In &lt;code&gt;2025-07&lt;/code&gt;, a store credit account's &lt;code&gt;owner&lt;/code&gt; is always a &lt;code&gt;Customer&lt;/code&gt;. In &lt;code&gt;2025-10&lt;/code&gt;, the owner can be a &lt;code&gt;Customer&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; a &lt;code&gt;CompanyLocation&lt;/code&gt; (B2B). If your code reads &lt;code&gt;owner&lt;/code&gt; assuming it's a customer — an inline fragment that only handles &lt;code&gt;... on Customer&lt;/code&gt;, or a parser that expects &lt;code&gt;owner.firstName&lt;/code&gt; — B2B-owned accounts now resolve to a shape you don't handle. No error: just an empty or null branch where a value used to be. Store-credit balances silently stop reconciling for your B2B accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;ProductVariant.taxCode&lt;/code&gt; deprecated (Avalara AvaTax sunset).&lt;/strong&gt;&lt;br&gt;
As of &lt;code&gt;2025-10&lt;/code&gt;, &lt;code&gt;ProductVariant.taxCode&lt;/code&gt; is deprecated as part of Shopify retiring the legacy AvaTax integration path. A 2025-07 client that reads &lt;code&gt;taxCode&lt;/code&gt; to drive tax classification doesn't get an exception when it falls forward — it gets a field that's on its way out, with values that may no longer mean what they did. Tax logic keyed on that field drifts without a single failed request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;InventoryItem.variant&lt;/code&gt; → &lt;code&gt;InventoryItem.variants&lt;/code&gt; (singular replaced by a connection).&lt;/strong&gt;&lt;br&gt;
The singular &lt;code&gt;InventoryItem.variant&lt;/code&gt; field was deprecated in favor of an &lt;code&gt;InventoryItem.variants&lt;/code&gt; connection. Code written for &lt;code&gt;2025-07&lt;/code&gt; that walks &lt;code&gt;inventoryItem.variant.id&lt;/code&gt; is reading a field on the deprecation path; once it's removed in a later version your fall-forward eventually reaches, that read returns &lt;code&gt;null&lt;/code&gt; and your inventory sync quietly maps to nothing.&lt;/p&gt;

&lt;p&gt;These three are just what's visible one quarter forward. The deeper problem: fall-forward doesn't move you one version — it moves you to &lt;em&gt;whatever the oldest supported version happens to be on the day yours expires.&lt;/em&gt; The longer a pin sits unmaintained, the bigger the silent jump when it finally lapses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do before July 16
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find every place you pin &lt;code&gt;2025-07&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;X-Shopify-Api-Version&lt;/code&gt; headers, GraphQL/REST URL paths (&lt;code&gt;/admin/api/2025-07/...&lt;/code&gt;), SDK config, webhook subscription versions, and any vendored client library's default. Webhooks have their own version setting; don't assume the API pin covers them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bump to a supported version and test against its schema&lt;/strong&gt;, not just "does it still return 200." Move to &lt;code&gt;2025-10&lt;/code&gt; or newer deliberately, read the release notes for the versions you're skipping, and fix the field-level changes on &lt;em&gt;your&lt;/em&gt; schedule instead of inheriting them on Shopify's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert on &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; in your client.&lt;/strong&gt; Log it, and alarm if the served version ever differs from the version you requested. That one check turns a silent fall-forward into a loud, fixable signal — which is exactly the difference between finding this in CI and finding it in a chargeback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The version sunset itself is well-documented. The trap is that "well-documented" and "fails loudly" are not the same thing. Shopify keeps your store running by quietly handing you a newer contract — and a &lt;code&gt;200&lt;/code&gt; with the wrong shape is harder to catch than an honest error.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your API responses for exactly this: a field that changes type, a value that goes null, a response shape that drifts — including the silent version fall-forwards that pinned clients can't see. If the contract you depend on changes, you hear about it from us before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>api</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Shopify retires API version 2025-07 on July 16 — pinned apps don't error, they silently fall forward to 2025-10</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 04 Jul 2026 05:00:37 +0000</pubDate>
      <link>https://dev.to/flarecanary/shopify-retires-api-version-2025-07-on-july-16-pinned-apps-dont-error-they-silently-fall-2hfi</link>
      <guid>https://dev.to/flarecanary/shopify-retires-api-version-2025-07-on-july-16-pinned-apps-dont-error-they-silently-fall-2hfi</guid>
      <description>&lt;p&gt;On &lt;strong&gt;July 16, 2026 at 15:00 UTC&lt;/strong&gt;, Shopify stops serving API version &lt;code&gt;2025-07&lt;/code&gt;. If your app, integration, or script still pins that version, here is the part that catches people off guard:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your requests do not start failing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;400&lt;/code&gt;, no &lt;code&gt;406&lt;/code&gt;, no "unsupported version" error. Shopify's documented behavior is to &lt;em&gt;fall forward&lt;/em&gt;: a request for an inaccessible version is served using the &lt;strong&gt;oldest accessible stable version&lt;/strong&gt; instead. After July 16, that's &lt;code&gt;2025-10&lt;/code&gt;. So a call that says &lt;code&gt;X-Shopify-Api-Version: 2025-07&lt;/code&gt; quietly gets answered by &lt;code&gt;2025-10&lt;/code&gt; — a schema you never tested against, on a date you didn't pick.&lt;/p&gt;

&lt;p&gt;Same endpoint. Same &lt;code&gt;200 OK&lt;/code&gt;. Different response shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the dangerous kind of breaking change
&lt;/h2&gt;

&lt;p&gt;Most deprecation posts treat a version sunset like a model retirement: there's a date, calls stop working, you migrate before it. Loud, scheduled, hard to miss.&lt;/p&gt;

&lt;p&gt;Shopify version sunsets are the opposite. The whole point of fall-forward is that &lt;strong&gt;nothing breaks loudly&lt;/strong&gt; — Shopify keeps answering you so your store doesn't go dark. The cost of that graceful behavior is that the moment your pinned version disappears, your client silently starts consuming a newer contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fields deprecated-then-removed between &lt;code&gt;2025-07&lt;/code&gt; and &lt;code&gt;2025-10&lt;/code&gt; come back &lt;strong&gt;&lt;code&gt;null&lt;/code&gt;&lt;/strong&gt; or vanish from the payload.&lt;/li&gt;
&lt;li&gt;Types that &lt;em&gt;widened&lt;/em&gt; now return shapes your parsing code never expected.&lt;/li&gt;
&lt;li&gt;Enum values, default behaviors, and pagination semantics shift to the newer version's rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it raises an exception on Shopify's side. The break happens &lt;strong&gt;inside your code&lt;/strong&gt;, when it reads a field that moved and gets back something it wasn't written for. That's a margin report three weeks later, not a page at 3 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only signal is a response header
&lt;/h2&gt;

&lt;p&gt;Shopify does tell you which version actually served the request — in the &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; response header. After fall-forward, that header reads &lt;code&gt;2025-10&lt;/code&gt; even though you asked for &lt;code&gt;2025-07&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the catch: the signal is on &lt;strong&gt;every response&lt;/strong&gt;, but it's on the part of the response almost nobody inspects. Most clients read the JSON body and ignore the headers entirely. There is no Sunset header on the body, no error field, no deprecation block in the payload. If you're not diffing &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; against the version you sent, the fall-forward is invisible until behavior drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three concrete shifts a 2025-07 app inherits on July 16
&lt;/h2&gt;

&lt;p&gt;This isn't hypothetical. Here are real &lt;code&gt;2025-07&lt;/code&gt; → &lt;code&gt;2025-10&lt;/code&gt; Admin API changes your pinned client silently adopts the instant it falls forward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;StoreCreditAccount.owner&lt;/code&gt; widened from one type to a union.&lt;/strong&gt;&lt;br&gt;
In &lt;code&gt;2025-07&lt;/code&gt;, a store credit account's &lt;code&gt;owner&lt;/code&gt; is always a &lt;code&gt;Customer&lt;/code&gt;. In &lt;code&gt;2025-10&lt;/code&gt;, the owner can be a &lt;code&gt;Customer&lt;/code&gt; &lt;strong&gt;or&lt;/strong&gt; a &lt;code&gt;CompanyLocation&lt;/code&gt; (B2B). If your code reads &lt;code&gt;owner&lt;/code&gt; assuming it's a customer — an inline fragment that only handles &lt;code&gt;... on Customer&lt;/code&gt;, or a parser that expects &lt;code&gt;owner.firstName&lt;/code&gt; — B2B-owned accounts now resolve to a shape you don't handle. No error: just an empty or null branch where a value used to be. Store-credit balances silently stop reconciling for your B2B accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;ProductVariant.taxCode&lt;/code&gt; deprecated (Avalara AvaTax sunset).&lt;/strong&gt;&lt;br&gt;
As of &lt;code&gt;2025-10&lt;/code&gt;, &lt;code&gt;ProductVariant.taxCode&lt;/code&gt; is deprecated as part of Shopify retiring the legacy AvaTax integration path. A 2025-07 client that reads &lt;code&gt;taxCode&lt;/code&gt; to drive tax classification doesn't get an exception when it falls forward — it gets a field that's on its way out, with values that may no longer mean what they did. Tax logic keyed on that field drifts without a single failed request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;InventoryItem.variant&lt;/code&gt; → &lt;code&gt;InventoryItem.variants&lt;/code&gt; (singular replaced by a connection).&lt;/strong&gt;&lt;br&gt;
The singular &lt;code&gt;InventoryItem.variant&lt;/code&gt; field was deprecated in favor of an &lt;code&gt;InventoryItem.variants&lt;/code&gt; connection. Code written for &lt;code&gt;2025-07&lt;/code&gt; that walks &lt;code&gt;inventoryItem.variant.id&lt;/code&gt; is reading a field on the deprecation path; once it's removed in a later version your fall-forward eventually reaches, that read returns &lt;code&gt;null&lt;/code&gt; and your inventory sync quietly maps to nothing.&lt;/p&gt;

&lt;p&gt;These three are just what's visible one quarter forward. The deeper problem: fall-forward doesn't move you one version — it moves you to &lt;em&gt;whatever the oldest supported version happens to be on the day yours expires.&lt;/em&gt; The longer a pin sits unmaintained, the bigger the silent jump when it finally lapses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do before July 16
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find every place you pin &lt;code&gt;2025-07&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;X-Shopify-Api-Version&lt;/code&gt; headers, GraphQL/REST URL paths (&lt;code&gt;/admin/api/2025-07/...&lt;/code&gt;), SDK config, webhook subscription versions, and any vendored client library's default. Webhooks have their own version setting; don't assume the API pin covers them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bump to a supported version and test against its schema&lt;/strong&gt;, not just "does it still return 200." Move to &lt;code&gt;2025-10&lt;/code&gt; or newer deliberately, read the release notes for the versions you're skipping, and fix the field-level changes on &lt;em&gt;your&lt;/em&gt; schedule instead of inheriting them on Shopify's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert on &lt;code&gt;X-Shopify-API-Version&lt;/code&gt; in your client.&lt;/strong&gt; Log it, and alarm if the served version ever differs from the version you requested. That one check turns a silent fall-forward into a loud, fixable signal — which is exactly the difference between finding this in CI and finding it in a chargeback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The version sunset itself is well-documented. The trap is that "well-documented" and "fails loudly" are not the same thing. Shopify keeps your store running by quietly handing you a newer contract — and a &lt;code&gt;200&lt;/code&gt; with the wrong shape is harder to catch than an honest error.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your API responses for exactly this: a field that changes type, a value that goes null, a response shape that drifts — including the silent version fall-forwards that pinned clients can't see. If the contract you depend on changes, you hear about it from us before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>api</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>The Google Merchant API migration has a silent mispricing trap (Content API shuts down Aug 18, 2026)</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 01 Jul 2026 05:01:04 +0000</pubDate>
      <link>https://dev.to/flarecanary/the-google-merchant-api-migration-has-a-silent-mispricing-trap-content-api-shuts-down-aug-18-2026-2gkn</link>
      <guid>https://dev.to/flarecanary/the-google-merchant-api-migration-has-a-silent-mispricing-trap-content-api-shuts-down-aug-18-2026-2gkn</guid>
      <description>&lt;p&gt;If you push product data to Google Shopping through a custom integration — a feed builder, a PIM sync job, an internal Node/Python service that calls &lt;code&gt;shoppingcontent.googleapis.com&lt;/code&gt; — you are on a clock. On &lt;strong&gt;August 18, 2026&lt;/strong&gt;, Google permanently shuts down the Content API for Shopping. Every &lt;code&gt;products.insert&lt;/code&gt;, every &lt;code&gt;products.list&lt;/code&gt;, every inventory and price update against the v2.1 endpoints stops returning data and starts returning errors. There is no soft cutoff and no grace period after that date.&lt;/p&gt;

&lt;p&gt;That part is loud. You'll know within minutes because the calls 404, the feed job alerts, and the catalog stops updating.&lt;/p&gt;

&lt;p&gt;The part that bites quietly is the migration itself. The &lt;a href="https://developers.google.com/merchant/api/guides/compatibility/overview" rel="noopener noreferrer"&gt;Merchant API&lt;/a&gt; is not a renamed Content API — it's an architectural rebuild with new resource shapes, a new identifier scheme, and a new money type. A migration done as a search-and-replace of the base URL will compile, authenticate, return &lt;code&gt;200&lt;/code&gt;, and silently write wrong data to your live catalog. Here are the three surfaces where that happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Content API for Shopping&lt;/th&gt;
&lt;th&gt;Merchant API v1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price amount&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;price.value&lt;/code&gt; — decimal &lt;strong&gt;string&lt;/strong&gt; (&lt;code&gt;"15.99"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;price.amountMicros&lt;/code&gt; — &lt;strong&gt;int64&lt;/strong&gt; (&lt;code&gt;15990000&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price currency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;price.currency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;price.currencyCode&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource identity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;merchantId&lt;/code&gt; + &lt;code&gt;productId&lt;/code&gt; params&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name&lt;/code&gt;: &lt;code&gt;accounts/{account}/products/{product}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product ID format&lt;/td&gt;
&lt;td&gt;&lt;code&gt;channel:contentLanguage:feedLabel:offerId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contentLanguage~feedLabel~offerId&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ID delimiter&lt;/td&gt;
&lt;td&gt;colon (&lt;code&gt;:&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;tilde (&lt;code&gt;~&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;channel&lt;/code&gt; in ID&lt;/td&gt;
&lt;td&gt;present (&lt;code&gt;online&lt;/code&gt; / &lt;code&gt;local&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;removed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth scope&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://www.googleapis.com/auth/content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;per-sub-API scopes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of those rows produce no error when you get them wrong. They produce &lt;em&gt;wrong data that the API happily accepts&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The &lt;code&gt;amountMicros&lt;/code&gt; trap silently misprices your entire catalog
&lt;/h2&gt;

&lt;p&gt;This is the one to fix before you touch anything else.&lt;/p&gt;

&lt;p&gt;In the Content API, a price is a decimal string with a sibling currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15.99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Merchant API, the amount is an &lt;strong&gt;int64 count of micros&lt;/strong&gt;, where one million micros equals one unit of currency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amountMicros"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15990000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currencyCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;$15.99&lt;/code&gt; is &lt;code&gt;15990000&lt;/code&gt;. The conversion is &lt;code&gt;Math.round(parseFloat(value) * 1_000_000)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now look at what a field-renaming migration does. The natural instinct, when a guide says &lt;em&gt;"the amount field name changed from &lt;code&gt;value&lt;/code&gt; to &lt;code&gt;amountMicros&lt;/code&gt;"&lt;/em&gt;, is to map the old field onto the new name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG — renames the field, skips the unit conversion&lt;/span&gt;
&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;amountMicros&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oldProduct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// "15.99"&lt;/span&gt;
  &lt;span class="na"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oldProduct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;amountMicros&lt;/code&gt; is typed as int64, so the string &lt;code&gt;"15.99"&lt;/code&gt; either gets coerced to &lt;code&gt;15&lt;/code&gt; (truncated) or rejected depending on your client library — and &lt;code&gt;15&lt;/code&gt; micros is &lt;strong&gt;$0.000015&lt;/strong&gt;. Every product in the feed is now listed at effectively zero. Google accepts the write. The products stay "active." Your Shopping ads start serving at a price that doesn't match your landing page, which trips Google's price-mismatch checks &lt;em&gt;days later&lt;/em&gt; — long after the deploy, with no stack trace pointing back at the migration.&lt;/p&gt;

&lt;p&gt;The inverse mistake is just as quiet and more expensive: a team that knows about micros but applies the multiply twice (once in a mapping layer, once in a helper) ships &lt;code&gt;15990000000000&lt;/code&gt; and lists a $16 product at $16 million. It won't sell, but it also won't error — it just silently stops converting.&lt;/p&gt;

&lt;p&gt;There is no error path here. The only way to catch it is to diff the prices that landed in Merchant Center against the prices you intended to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The product ID delimiter flips from &lt;code&gt;:&lt;/code&gt; to &lt;code&gt;~&lt;/code&gt; — and drops &lt;code&gt;channel&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Content API product IDs are colon-delimited and lead with the channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;online:en:US:SKU12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Merchant API product IDs are tilde-delimited and &lt;strong&gt;have no channel segment&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en~US~SKU12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any code that builds or parses product IDs by string manipulation breaks silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// builds a malformed ID against the new API&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;feedLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// still using ':' and channel&lt;/span&gt;

&lt;span class="c1"&gt;// parsing an inbound Merchant API id by the old delimiter&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// no ':' present → channel = whole string, rest undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build path creates a product the API treats as &lt;em&gt;a different offer&lt;/em&gt; than the one you meant to update — so instead of updating SKU12345 you insert a duplicate, and the original goes stale until it expires. The parse path silently mis-shards your records, and because &lt;code&gt;offer&lt;/code&gt; comes back &lt;code&gt;undefined&lt;/code&gt;, downstream lookups quietly miss.&lt;/p&gt;

&lt;p&gt;The dropped &lt;code&gt;channel&lt;/code&gt; segment is its own trap: in the old format &lt;code&gt;online:...&lt;/code&gt; and &lt;code&gt;local:...&lt;/code&gt; were distinct products. The Merchant API moves the online/local distinction out of the identifier entirely. If your reconciliation logic keyed products by the full colon-string, your online and local variants now collide on the same key.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;id&lt;/code&gt; becomes &lt;code&gt;name&lt;/code&gt;, and scripts reading &lt;code&gt;.id&lt;/code&gt; get &lt;code&gt;undefined&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The Content API returned a flat &lt;code&gt;id&lt;/code&gt; on every product. The Merchant API follows Google's &lt;a href="https://google.aip.dev/122" rel="noopener noreferrer"&gt;resource-name convention&lt;/a&gt; — the unique identifier is &lt;code&gt;name&lt;/code&gt;, formatted &lt;code&gt;accounts/{account}/products/{product}&lt;/code&gt;. There is no top-level &lt;code&gt;id&lt;/code&gt; field on the new product resource.&lt;/p&gt;

&lt;p&gt;Every script that does &lt;code&gt;product.id&lt;/code&gt;, every database column populated from &lt;code&gt;response.id&lt;/code&gt;, every log line keyed on the product ID reads &lt;code&gt;undefined&lt;/code&gt; after the cutover. Nothing throws — JavaScript hands you &lt;code&gt;undefined&lt;/code&gt;, Python hands you a &lt;code&gt;KeyError&lt;/code&gt; only if you used &lt;code&gt;[]&lt;/code&gt; instead of &lt;code&gt;.get()&lt;/code&gt;, and most feed code uses &lt;code&gt;.get()&lt;/code&gt; defensively precisely so a missing field doesn't crash the run. So the field goes missing and the run keeps going, writing &lt;code&gt;null&lt;/code&gt; IDs into your sync table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for before you cut over
&lt;/h2&gt;

&lt;p&gt;A focused audit on every service that talks to &lt;code&gt;shoppingcontent.googleapis.com&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "shoppingcontent.googleapis.com\|content/v2" .&lt;/code&gt; — every match is an endpoint that 404s on August 18, 2026.&lt;/li&gt;
&lt;li&gt;Search your price-mapping layer for the move to &lt;code&gt;amountMicros&lt;/code&gt;. Confirm there is exactly &lt;strong&gt;one&lt;/strong&gt; &lt;code&gt;* 1_000_000&lt;/code&gt; (or &lt;code&gt;* 1e6&lt;/code&gt;) on the path from your stored price to the request body — not zero, not two.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "\.split(':')\|:\${" .&lt;/code&gt; near product-ID construction — colon-delimited ID logic that needs to become tilde-delimited and channel-free.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "\.id\b" .&lt;/code&gt; in your product sync code — reads that now need to be &lt;code&gt;.name&lt;/code&gt;, and any DB column fed from the old flat &lt;code&gt;id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check your OAuth flow: the blanket &lt;code&gt;auth/content&lt;/code&gt; scope is replaced by per-sub-API scopes. A token minted with only the old scope authorizes nothing on the new endpoints — and if you request a partial set, the calls that lack scope fail individually while the rest succeed, so a half-migrated scope grant looks like an intermittent outage rather than a config error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The August 18 shutdown will get attention because it's a date on a calendar with a hard error attached. The migration that everyone does in the weeks before it is where the silent damage lives: a catalog that's fully "active" in Merchant Center, priced at $0.00 or $16 million, with duplicate SKUs and null IDs in your sync table — and not one line in the logs to say so.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary monitors API responses for schema drift, silent removals, and behavior changes across upstream providers. If you sync product data to Google Shopping, Shopify, or anywhere else that ships breaking changes through migration guides, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt; catches the drift before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>api</category>
      <category>googleshopping</category>
      <category>integrations</category>
    </item>
    <item>
      <title>HighLevel webhooks flip X-WH-Signature X-GHL-Signature on July 1, 2026 — three silent failure modes</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 28 Jun 2026 05:00:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/highlevel-webhooks-flip-x-wh-signature-x-ghl-signature-on-july-1-2026-three-silent-failure-do2</link>
      <guid>https://dev.to/flarecanary/highlevel-webhooks-flip-x-wh-signature-x-ghl-signature-on-july-1-2026-three-silent-failure-do2</guid>
      <description>&lt;p&gt;If you run a HighLevel integration — an n8n flow, a custom CRM bridge, a Make scenario, an internal Node service that listens for "Opportunity stage changed" — you have five weeks. On &lt;strong&gt;July 1, 2026&lt;/strong&gt;, HighLevel deprecates the legacy &lt;code&gt;X-WH-Signature&lt;/code&gt; (RSA) webhook signature header. After that date, every webhook is signed only with &lt;code&gt;X-GHL-Signature&lt;/code&gt; using &lt;strong&gt;Ed25519&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://marketplace.gohighlevel.com/docs/webhook/WebhookIntegrationGuide/index.html" rel="noopener noreferrer"&gt;HighLevel Webhook Integration Guide&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The legacy header **X-WH-Signature&lt;/em&gt;* will be &lt;strong&gt;deprecated on July 1, 2026&lt;/strong&gt;. After that date, webhooks will be signed only with &lt;strong&gt;X-GHL-Signature&lt;/strong&gt;."*&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The loud failure mode is the one everyone plans around. Your &lt;code&gt;crypto.verify(...)&lt;/code&gt; call returns &lt;code&gt;false&lt;/code&gt;, HighLevel sees a non-2xx from your endpoint, retries exhaust, the automation chain dies. You notice within an hour because the "new lead created" Slack notifier stops firing.&lt;/p&gt;

&lt;p&gt;The failure modes worth grepping for &lt;em&gt;now&lt;/em&gt; are the ones that don't surface as a 401. Three of them are common enough that they'll bite a meaningful chunk of the agency-built integrations running on HighLevel today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changes
&lt;/h2&gt;

&lt;p&gt;Two things change simultaneously, and that's part of the trap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Before Jul 1, 2026&lt;/th&gt;
&lt;th&gt;After Jul 1, 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Signature header&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-WH-Signature&lt;/code&gt; (and &lt;code&gt;X-GHL-Signature&lt;/code&gt; when present)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-GHL-Signature&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Algorithm&lt;/td&gt;
&lt;td&gt;RSA (SHA-256)&lt;/td&gt;
&lt;td&gt;Ed25519&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public key&lt;/td&gt;
&lt;td&gt;RSA public key endpoint&lt;/td&gt;
&lt;td&gt;Ed25519 public key endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signature length&lt;/td&gt;
&lt;td&gt;256 bytes (2048-bit RSA)&lt;/td&gt;
&lt;td&gt;64 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verifier API (Node)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.createVerify('SHA256').update(body).verify(pubkey, sig)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;crypto.verify(null, body, pubkey, sig)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two headers. Two algorithms. Two public keys. Two completely different verifier call shapes. A migration that's &lt;em&gt;only&lt;/em&gt; a string replace from &lt;code&gt;x-wh-signature&lt;/code&gt; to &lt;code&gt;x-ghl-signature&lt;/code&gt; is silently broken on at least three of those rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The "skip if signature missing" branch becomes an open endpoint
&lt;/h2&gt;

&lt;p&gt;This is the one to grep for first. Pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook/highlevel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-wh-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// dev/test environments don't sign — skip verification&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handlePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyRSA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RSA_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bad sig&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;handlePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;if (!sig)&lt;/code&gt; early-return exists in roughly every webhook handler I've seen in agency-shipped HighLevel code. It's there because somebody wanted to test locally without setting up signature verification, or because an older version of HighLevel really did skip signing on certain event types.&lt;/p&gt;

&lt;p&gt;After July 1, 2026, &lt;strong&gt;every&lt;/strong&gt; production webhook from HighLevel arrives with &lt;code&gt;X-WH-Signature&lt;/code&gt; absent. The handler reads &lt;code&gt;req.headers['x-wh-signature']&lt;/code&gt;, gets &lt;code&gt;undefined&lt;/code&gt;, takes the skip-verify branch, and processes the payload. Your endpoint is now unauthenticated. Anyone who knows the URL can POST arbitrary JSON to it — fake "stage changed to Won" events that trigger your billing automation, fake "new contact" events that pollute your CRM.&lt;/p&gt;

&lt;p&gt;The fix is one line — switch the header name and remove the skip-verify branch — but the open-endpoint window between now and the fix is exactly the kind of thing that doesn't show up in logs because &lt;em&gt;the requests succeed&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RSA verifier passed an Ed25519 signature returns false (silently)
&lt;/h2&gt;

&lt;p&gt;The migration guide says to use &lt;code&gt;X-GHL-Signature&lt;/code&gt; and provides Ed25519 verification snippets. What it doesn't emphasize is that &lt;strong&gt;you cannot mix and match the verifier call with the new header&lt;/strong&gt;. Code that updates the header but keeps the RSA verifier looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-ghl-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;           &lt;span class="c1"&gt;// updated&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// not updated&lt;/span&gt;
&lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RSA_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ok === false, because sig is a 64-byte Ed25519 sig&lt;/span&gt;
&lt;span class="c1"&gt;// and verifier is checking RSA-PSS / RSA-PKCS1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two outcomes, both bad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your handler returns 401 on &lt;code&gt;!ok&lt;/code&gt;, HighLevel retries with exponential backoff, gives up after the retry window, and the event is &lt;strong&gt;lost&lt;/strong&gt;. No alert fires — HighLevel doesn't push failed-delivery dashboards into the agency dashboard, and most agency setups don't subscribe to delivery-failure events.&lt;/li&gt;
&lt;li&gt;If your handler logs but still processes (an "alert on verify failure but proceed" pattern that some teams use during cutover), you're back to unauthenticated processing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the symptom is the same the entire production stack from July 1 onward: deliveries look 200-green from HighLevel's side until you check the actual content delivered to your downstream system, and they look fine from your side until you realize the automations that should have fired never did.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;body-parser&lt;/code&gt; + RSA-verifier stack ported to Ed25519 also needs to handle the body differently — &lt;code&gt;crypto.createVerify&lt;/code&gt; takes streamed updates; &lt;code&gt;crypto.verify&lt;/code&gt; with &lt;code&gt;null&lt;/code&gt; algorithm wants the full buffer. If your middleware already consumed the stream into a JSON object, you need the &lt;strong&gt;raw body&lt;/strong&gt; preserved (&lt;code&gt;bodyParser.raw&lt;/code&gt; or &lt;code&gt;express.raw&lt;/code&gt; with a &lt;code&gt;verify&lt;/code&gt; callback to stash &lt;code&gt;req.rawBody&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cached public keys work right up until the cutoff
&lt;/h2&gt;

&lt;p&gt;A lot of agency setups load the HighLevel RSA public key into an environment variable at deploy time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HIGHLEVEL_PUBKEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-----BEGIN&lt;/span&gt; PUBLIC KEY-----&lt;span class="se"&gt;\n&lt;/span&gt;MIIBIjANBgkqhkiG9w0...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That key keeps working through June 30. On July 1, every signature is generated against a &lt;strong&gt;different keypair&lt;/strong&gt; — the Ed25519 keypair. Your env var is now matched against signatures it has never seen and cannot validate by definition.&lt;/p&gt;

&lt;p&gt;If you also took the time to switch verifiers (so issue #2 doesn't apply), you'll still fail because the public key is wrong. The verifier returns false. The signatures look "well-formed but invalid," which is indistinguishable in your logs from a real attack — and that's the worst place to be on July 1, because the on-call response to "all webhook signatures are suddenly invalid" is exactly the same as "we're being attacked": tighten the rate limit, page the security team, panic.&lt;/p&gt;

&lt;p&gt;The fix is to fetch the Ed25519 public key from HighLevel's published JWKS-equivalent endpoint &lt;strong&gt;at runtime&lt;/strong&gt; (with caching), not bake it into config. Even setting aside the July 1 cutoff, baking long-lived public keys into env vars is the pattern that makes every future key rotation a deploy event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to grep for this week
&lt;/h2&gt;

&lt;p&gt;A 30-minute audit on every HighLevel webhook handler in your fleet:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "x-wh-signature\|X-WH-Signature" .&lt;/code&gt; — every match is a code path that breaks on July 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "createVerify" .&lt;/code&gt; near webhook handlers — RSA verifier calls that need to become &lt;code&gt;crypto.verify(null, ...)&lt;/code&gt; Ed25519 calls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -rn "if (!sig\|if (!signature\|sig === undefined" .&lt;/code&gt; — skip-verify branches that turn into open endpoints.&lt;/li&gt;
&lt;li&gt;Search your env vars and secret stores for &lt;code&gt;HIGHLEVEL_PUBKEY&lt;/code&gt; or similar — anything pinned to the RSA key needs to be re-pointed at the Ed25519 key, ideally fetched at runtime.&lt;/li&gt;
&lt;li&gt;Confirm your webhook handler preserves the &lt;strong&gt;raw body&lt;/strong&gt; for Ed25519 verification — &lt;code&gt;body-parser&lt;/code&gt;'s default JSON middleware consumes the stream and loses the byte-exact payload that the signature covers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cutoff is hard. HighLevel is not running a parallel-signed period where both headers ship — once &lt;code&gt;X-WH-Signature&lt;/code&gt; is deprecated, it's gone. The thirty minutes spent grepping now is the difference between a quiet July 2 and explaining to a client why the "new lead → SMS notification" pipeline stopped firing five days into the month.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary monitors API responses for schema drift, silent removals, and behavior changes across upstream providers. If you run integrations against HighLevel, Stripe, Shopify, GitHub, or anywhere else that ships breaking changes through changelog posts, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt; catches the drift before your customers do.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>security</category>
      <category>integrations</category>
      <category>api</category>
    </item>
  </channel>
</rss>
