<?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>Meta's Graph API v20 expires September 24 — your calls won't fail, they'll quietly start running v21</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Thu, 06 Aug 2026 05:00:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/metas-graph-api-v20-expires-september-24-your-calls-wont-fail-theyll-quietly-start-running-v21-2m2a</link>
      <guid>https://dev.to/flarecanary/metas-graph-api-v20-expires-september-24-your-calls-wont-fail-theyll-quietly-start-running-v21-2m2a</guid>
      <description>&lt;p&gt;There are two ways a vendor can retire an API version. It can reject calls to the dead version, which is loud and annoying and gets fixed the same afternoon. Or it can keep serving those calls out of a newer version, which is polite, invisible, and much worse.&lt;/p&gt;

&lt;p&gt;Meta does the second one. From &lt;a href="https://developers.facebook.com/docs/graph-api/guides/versioning" rel="noopener noreferrer"&gt;Meta's Graph API versioning docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A version will no longer be usable two years after the date that the subsequent version is released.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and, immediately after:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For APIs, once a version is no longer usable, any calls made to it will be defaulted to the next oldest, usable version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Graph API v20.0 is listed in &lt;a href="https://developers.facebook.com/docs/graph-api/changelog" rel="noopener noreferrer"&gt;the changelog&lt;/a&gt; as "Available Until September 24, 2026."&lt;/strong&gt; It is the oldest version still available, and the only one that expires in 2026 — v21.0 runs until January 21, 2027, v22.0 until May 20, 2027.&lt;/p&gt;

&lt;p&gt;So on September 24, requests to &lt;code&gt;https://graph.facebook.com/v20.0/...&lt;/code&gt; do not start returning &lt;code&gt;400&lt;/code&gt;. They start getting answered by v21 code. The path in your request still reads &lt;code&gt;v20.0&lt;/code&gt;. Your dashboards still show 200s. Nothing in the response announces that the switch happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure modes split into two groups, and only one of them pages you
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalizing before September, because it determines what your incident review will miss.&lt;/p&gt;

&lt;p&gt;Everything that changed between v20 and v21 lands on your integration at the same instant, from the same cause. But it doesn't land the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The loud group&lt;/strong&gt; — you'll find these in your error logs within a day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;POST /{app_id}/page_activities&lt;/code&gt; is gone.&lt;/strong&gt; The Messaging Events API is, per the v21 changelog, "no longer supported in any future releases of Graph API, starting with version 21.0." Calls 404.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removed Insights metrics return real errors.&lt;/strong&gt; Request &lt;code&gt;video_views&lt;/code&gt; on IG media insights and you get &lt;code&gt;(#100) Starting from version 21+, the following metric is no longer supported: video_views&lt;/code&gt;. That's an explicit, greppable error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy campaign objectives stop being creatable.&lt;/strong&gt; On v21 you can't create new ad sets or ads with non-ODAX objectives via &lt;code&gt;POST /{ad_account_id}/campaigns&lt;/code&gt;, &lt;code&gt;/adsets&lt;/code&gt;, or &lt;code&gt;/ads&lt;/code&gt;. Creation fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image Expansion moved.&lt;/strong&gt; It's now part of Standard Enhancements and has to be set via &lt;code&gt;standard_enhancements&lt;/code&gt; in &lt;code&gt;creative_features_spec&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix these and it feels like you've handled the migration. You have not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quiet group&lt;/strong&gt; is everything that changed shape or value without changing status code. And the six Instagram User Insights time-series metrics removed in v21 — &lt;code&gt;profile_views&lt;/code&gt;, &lt;code&gt;website_clicks&lt;/code&gt;, &lt;code&gt;email_contacts&lt;/code&gt;, &lt;code&gt;get_direction_clicks&lt;/code&gt;, &lt;code&gt;text_message_clicks&lt;/code&gt;, &lt;code&gt;phone_call_clicks&lt;/code&gt; — are the ones to watch, because of how they're usually consumed rather than how the API responds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API is loud. Your pipeline is quiet.
&lt;/h2&gt;

&lt;p&gt;Here's the trap, and it's a pipeline problem more than an API problem.&lt;/p&gt;

&lt;p&gt;Insights endpoints take a comma-separated &lt;code&gt;metric&lt;/code&gt; list. A nightly ETL that pulls twelve metrics per account across four hundred accounts does not, as a rule, fail the whole run when Meta rejects one of them. It does something like this:&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;for&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;METRICS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_insights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;GraphAPIError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipping %s for %s: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code is not wrong. It's the code you write after the third time one bad account killed an eight-hour backfill. But on September 24 it converts an explicit &lt;code&gt;(#100)&lt;/code&gt; error into a &lt;code&gt;WARNING&lt;/code&gt; line in a log nobody reads, and the dashboard downstream renders a metric that simply stops having data — not a gap with an error banner, just a line that goes flat and a "profile views" tile that reads 0.&lt;/p&gt;

&lt;p&gt;Zero is a plausible number. That's the whole problem. Nobody files a ticket about a chart that shows a small number; they file tickets about charts that show an error. Reporting built on this quietly becomes wrong on a Thursday and gets discovered in a quarterly review, if at all.&lt;/p&gt;

&lt;p&gt;The same shape applies to any per-field &lt;code&gt;try/except&lt;/code&gt;, any &lt;code&gt;IGNORE_MALFORMED&lt;/code&gt;-style ingest setting, any connector that treats a partial response as a successful sync. If you run Airbyte, Fivetran, Supermetrics, or a homegrown equivalent against Meta, the question isn't "does the API error" — it's "what does my connector do with a metric-level error inside an otherwise-successful call."&lt;/p&gt;

&lt;h2&gt;
  
  
  Grep won't find all of it
&lt;/h2&gt;

&lt;p&gt;The obvious audit is to search for &lt;code&gt;v20.0&lt;/code&gt; across your codebase. Do that first, but know that it's the easy half. The version pin also lives in places that don't contain that string:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App-level default version.&lt;/strong&gt; Calls made without an explicit version in the path resolve to a version configured for your app, not one written in your code. Open the App Dashboard and check what your app is actually set to before you conclude you're not affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK pins.&lt;/strong&gt; Meta is explicit that this is a landmine: "For SDKs, a version will always remain available as it is a downloadable package. However, the SDK may rely upon APIs or methods which no longer work, so you should assume an end-of-life SDK is no longer functional." A pinned &lt;code&gt;facebook-business&lt;/code&gt; SDK from 2024 is a v20 client wearing a version number that never expires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connector configuration, not code.&lt;/strong&gt; Airbyte/Fivetran/Supermetrics/Zapier/n8n Meta connectors store an API version in saved config. It's in a database row, not your repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile SDKs shipped to devices.&lt;/strong&gt; You don't control the upgrade cadence of an app someone installed in 2024.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saved webhook subscriptions&lt;/strong&gt;, which were created against a version at subscription time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The useful part: you can diff this today
&lt;/h2&gt;

&lt;p&gt;The thing that makes this different from most deprecation posts is that the "after" state already exists. v21 through v25 are live right now. You do not have to wait for September 24 to find out what changes — you can find out this week, with seven weeks left to act.&lt;/p&gt;

&lt;p&gt;The check that actually pays:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture your real query set.&lt;/strong&gt; Not a representative sample you wrote by hand — the actual distinct (endpoint, fields, metrics) tuples your integration issues in a week. Pull them from access logs or add a one-line logger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay each one against &lt;code&gt;/v20.0/&lt;/code&gt; and &lt;code&gt;/v21.0/&lt;/code&gt; back to back&lt;/strong&gt;, same token, same params, same time window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff on shape and value, not status.&lt;/strong&gt; Both will mostly return 200. What you're looking for is: keys present in one and absent in the other, &lt;code&gt;data&lt;/code&gt; arrays that come back shorter, numbers that moved more than rounding explains, and enum values in the v21 response that your code doesn't have a branch for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the same replay through your pipeline, not just curl.&lt;/strong&gt; The &lt;code&gt;(#100)&lt;/code&gt; errors are only interesting in terms of what your ingest code does with them. Point a staging connector at v21 and see what lands in the warehouse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the one people skip and it's the one that matters, because as established, the API's honesty is not the issue. Your error handling is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this pattern keeps showing up
&lt;/h2&gt;

&lt;p&gt;Fall-forward versioning — serving expired versions out of newer code instead of rejecting them — is now the default posture at Meta, Klaviyo, monday.com, Contentful, and Xero. The vendor logic is sound: nobody's integration goes dark on a Tuesday, and the long tail of unmaintained clients keeps limping along.&lt;/p&gt;

&lt;p&gt;The cost is borne somewhere else. It converts an availability failure, which your monitoring is built to catch, into a correctness failure, which it usually isn't. A version string in a URL stops being a contract and becomes a preference the server honors right up until the day it doesn't — while still echoing your preference back to you.&lt;/p&gt;

&lt;p&gt;The defense isn't to stop pinning versions. It's to treat every pinned version in your stack as a dated liability with a known expiry, and to assert on the shape of what comes back instead of trusting that a 200 means the contract held.&lt;/p&gt;

&lt;p&gt;For Meta specifically, the date is September 24, 2026, and the diff is available today.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about API drift — the changes that keep returning 200 while quietly changing what your integration receives. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; monitors API responses and schemas for exactly this class of change.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>integrations</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Klaviyo retired an API revision on July 15 — if you pinned it, your profile writes silently changed behavior</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 03 Aug 2026 05:01:52 +0000</pubDate>
      <link>https://dev.to/flarecanary/klaviyo-retired-an-api-revision-on-july-15-if-you-pinned-it-your-profile-writes-silently-changed-4m7d</link>
      <guid>https://dev.to/flarecanary/klaviyo-retired-an-api-revision-on-july-15-if-you-pinned-it-your-profile-writes-silently-changed-4m7d</guid>
      <description>&lt;p&gt;Most API deprecations announce themselves. You pin a version, the vendor retires it, and your next call comes back &lt;code&gt;410 Gone&lt;/code&gt; with a link to the migration guide. Annoying, but it's a signal — something in your monitoring goes red, someone gets paged, and the work gets scheduled.&lt;/p&gt;

&lt;p&gt;Klaviyo does something different, and it's written plainly in &lt;a href="https://developers.klaviyo.com/en/docs/api_versioning_and_deprecation_policy" rel="noopener noreferrer"&gt;their versioning policy&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a retired revision date is passed to the request header, Klaviyo falls forward and responds to your request with the same behavior as the next oldest revision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again with an on-call hat on. Your &lt;code&gt;revision: 2024-07-15&lt;/code&gt; header keeps getting sent. Klaviyo keeps returning 200. Your logs, your traces, your API client's user-agent string — all still say &lt;code&gt;2024-07-15&lt;/code&gt;. But the code path serving you changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revision &lt;code&gt;2024-07-15&lt;/code&gt; reached its support end date on July 15, 2026.&lt;/strong&gt; If you're pinned to it — or anything older — you've been served &lt;code&gt;2024-10-15&lt;/code&gt; behavior since then. And &lt;code&gt;2024-10-15&lt;/code&gt; itself retires on &lt;strong&gt;October 15, 2026&lt;/strong&gt;, which is a sharper cliff than the one that already passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fall-forward is worse than a hard failure
&lt;/h2&gt;

&lt;p&gt;The trade Klaviyo made is deliberate and defensible: don't break integrations that stopped being maintained. The cost is that "still works" and "still works &lt;em&gt;correctly&lt;/em&gt;" stop being the same thing, and nothing in the response distinguishes them.&lt;/p&gt;

&lt;p&gt;Klaviyo is direct about this in the same doc — relying on fall-forward is "likely to introduce unwanted, breaking changes in your app." There's a partial exception worth knowing: calls to endpoints that were &lt;em&gt;removed&lt;/em&gt; after your pinned revision do return a &lt;code&gt;410&lt;/code&gt;. So endpoint removals are loud. &lt;strong&gt;Behavior changes to endpoints that still exist are silent.&lt;/strong&gt; That asymmetry is the whole problem, because the July 15 cliff was entirely the second kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cliff one (already fired): profile identity resolution
&lt;/h2&gt;

&lt;p&gt;The breaking change in &lt;code&gt;2024-10-15&lt;/code&gt; — the behavior you're getting now if you pinned &lt;code&gt;2024-07-15&lt;/code&gt; — is on the Profiles API. Klaviyo's changelog describes it as updating the profile endpoints to an enhanced identity resolution processor that better follows Klaviyo's identifier priority order. It affects &lt;strong&gt;Create Profile&lt;/strong&gt; and &lt;strong&gt;Update Profile&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Identity resolution decides &lt;em&gt;which existing profile your write lands on&lt;/em&gt;. Klaviyo's precedence runs roughly: Klaviyo ID, then &lt;code&gt;external_id&lt;/code&gt;, then &lt;code&gt;email&lt;/code&gt;, then &lt;code&gt;phone_number&lt;/code&gt;, then anonymous/device ID. When you POST a profile carrying more than one identifier, that order determines whether you update an existing record, merge two records, or create a new one.&lt;/p&gt;

&lt;p&gt;Change the matching processor and you change the destination of a write that returns an identical 200 either way. Concretely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Multi-identifier writes can land on a different profile.&lt;/strong&gt; The classic payload from a store sync carries both a customer ID and an email:&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;"data"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&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;"external_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cust_88213"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sam@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"lifetime_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;412.00&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;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;If &lt;code&gt;external_id&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; point at &lt;em&gt;different&lt;/em&gt; existing profiles — and in real catalogs they often do — the resolution order decides the winner. Stale or reused external IDs are extremely common: guest checkouts that later register, customer records rebuilt during a platform migration, staging IDs that leaked into production, ID namespaces merged after an acquisition. When the tiebreak changes, &lt;code&gt;lifetime_value: 412.00&lt;/code&gt; gets written to a different human being than it did in June. Status: 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Writes that used to create now merge (and overwrite).&lt;/strong&gt; If the new processor matches an identifier the old one didn't, a call that previously created a fresh profile now updates an existing one — and custom properties on that existing profile get overwritten by your payload. Your "new profiles created today" metric drops, which reads like a top-of-funnel problem, not an API problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Consent state can land on the wrong profile.&lt;/strong&gt; This is the one to care about. Subscribe and unsubscribe writes resolve to a profile like any other write. A resolution change that redirects a consent write is a compliance problem wearing a 200 OK: someone who unsubscribed keeps receiving email because the suppression landed next door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Your billable profile count moves.&lt;/strong&gt; Klaviyo bills on active profiles. Duplicates and merges both shift that number, and the shift shows up on an invoice weeks after the code that caused it shipped — long past the point where anyone connects the two.&lt;/p&gt;

&lt;p&gt;None of these throw. There is no error rate to alert on. The only observable is data that is quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cliff two (October 15, 2026): the one to schedule work for
&lt;/h2&gt;

&lt;p&gt;On &lt;strong&gt;October 15, 2026&lt;/strong&gt;, &lt;code&gt;2024-10-15&lt;/code&gt; retires and anyone pinned to it falls forward to &lt;code&gt;2025-01-15&lt;/code&gt;. That revision carries three changes, and they fail in different ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offset pagination becomes cursor pagination&lt;/strong&gt; on &lt;code&gt;Get Messages for Flow Action&lt;/code&gt; and &lt;code&gt;Get Action ID for Flow Message&lt;/code&gt;. This is the silent-truncation shape that has bitten teams on Jira, monday.com, and Contentful this year. The canonical loop:&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;offset&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;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&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;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;getMessagesForFlowAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;page_offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offset&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;data&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// "short page = done"&lt;/span&gt;
  &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;PAGE_SIZE&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;Two independent ways this loses data under cursor pagination: the offset parameter stops steering the result set, and a non-final cursor page is not guaranteed to be full, so the short-page heuristic exits early. You get a plausible-looking partial list of flow messages. If that list feeds a content audit or a template-sync job, the missing tail is invisible — you can't miss messages you never learned existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Campaigns API restructures for push notification support.&lt;/strong&gt; &lt;code&gt;2025-01-15&lt;/code&gt; changed the request and response structure across Get Campaigns, Get Campaign, Create Campaign, Update Campaign, Create Campaign Clone, and the campaign-message endpoints. Reporting code that reaches into a nested field by path gets &lt;code&gt;undefined&lt;/code&gt; rather than an exception — and &lt;code&gt;undefined&lt;/code&gt; propagates into a dashboard as a zero or a blank far more often than it propagates as a stack trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk Subscribe/Unsubscribe Profiles now require the &lt;code&gt;subscriptions&lt;/code&gt; field.&lt;/strong&gt; This one is loud — a missing required field is a 4xx. Good. It's also the only one of the three you'll find out about on day one.&lt;/p&gt;

&lt;p&gt;So the October cliff is a mix, and the loud member of the set is the least dangerous.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;The audit is small and it beats reading the changelog diff by hand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Find every pinned revision.&lt;/strong&gt; Grep for the header across your services, your serverless functions, your ETL jobs, and — the one people forget — your official Klaviyo SDK version, which pins a revision internally:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.{js,ts,py,rb,php,go,java}'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'revision'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'2024-0'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'2024-10-15'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the third-party middleware too. iPaaS connectors, reverse-ETL tools, and agency-built integrations all pin revisions, and none of them will tell you when one retires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Sort what you find into three buckets.&lt;/strong&gt; Anything at &lt;code&gt;2024-07-15&lt;/code&gt; or older is &lt;em&gt;already&lt;/em&gt; being served newer behavior — that's not future work, that's a live audit of the last two weeks of profile writes. Anything at &lt;code&gt;2024-10-15&lt;/code&gt; has until October 15. Anything &lt;code&gt;2025-01-15&lt;/code&gt; or newer has runway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. For the already-fired group, look at the data, not the logs.&lt;/strong&gt; The logs are clean by construction. Query for profiles created since July 15 that duplicate an existing &lt;code&gt;external_id&lt;/code&gt; or email, and diff your daily new-profile count across the July 15 boundary. A step change on that date is your answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Pin deliberately, then monitor the pin.&lt;/strong&gt; Pinning a revision is correct — it's what the header is for. What breaks teams is pinning and then never revisiting, so the pin silently becomes a lie about which behavior you're getting. A retirement date is a known future date. Put it in the calendar the day you pin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;Fall-forward versioning is spreading — Klaviyo, monday.com, Contentful, and Xero have all shipped some flavor of it in the last year. The appeal to a vendor is obvious: nobody's integration goes dark on a Tuesday.&lt;/p&gt;

&lt;p&gt;But it converts a hard failure into a data-quality failure, and it moves detection from your error budget to whoever eventually notices the numbers look wrong. The version header stops being a contract and becomes a &lt;em&gt;preference&lt;/em&gt; — one the server is free to ignore once the date passes, while still echoing it back to you.&lt;/p&gt;

&lt;p&gt;The defensive move isn't to stop pinning. It's to treat every version header in your stack as something with an expiry date attached, and to assert on the shape of what comes back rather than trusting that a 200 means the contract held.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about API drift — the changes that keep returning 200 while quietly changing what your integration receives. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; monitors API responses and schemas for exactly this class of change.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>integrations</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Twilio removes conference_participant from Voice Insights on Aug 1, 2026 — the field goes null, not gone, so nothing errors</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 31 Jul 2026 05:00:46 +0000</pubDate>
      <link>https://dev.to/flarecanary/twilio-removes-conferenceparticipant-from-voice-insights-on-aug-1-2026-the-field-goes-null-not-4l52</link>
      <guid>https://dev.to/flarecanary/twilio-removes-conferenceparticipant-from-voice-insights-on-aug-1-2026-the-field-goes-null-not-4l52</guid>
      <description>&lt;p&gt;If you pull Twilio Voice Insights Call Summaries to analyze conference calls — contact-center reporting, a QA dashboard that scores participant talk-time, a reconciliation job that joins conference legs — there's a removal landing on &lt;strong&gt;August 1, 2026&lt;/strong&gt; that fails in the quietest way an API change can: the field you read doesn't disappear, it just stops carrying data. Same 200. Same JSON shape. &lt;code&gt;attributes: null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Twilio is &lt;a href="https://www.twilio.com/en-us/changelog/voice-insights-removal-conference-participant-field" rel="noopener noreferrer"&gt;removing the &lt;code&gt;conference_participant&lt;/code&gt; field&lt;/a&gt; from the Voice Insights Call Summary on August 1, 2026 — from &lt;strong&gt;both&lt;/strong&gt; the REST API and Event Streams. Their reasoning is fair: the field predates the purpose-built Conference Insights product, and its accuracy was always shaky because conference call flows are complicated. But the &lt;em&gt;mechanism&lt;/em&gt; of the removal is the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that was already mostly null
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes this silent instead of loud. From Twilio's own note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It is currently only populated for calls eligible to participate in a Twilio conference; all others return &lt;code&gt;attributes: null&lt;/code&gt;. After removal, all calls will uniformly return &lt;code&gt;attributes: null&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that twice. The field &lt;strong&gt;already&lt;/strong&gt; returns &lt;code&gt;null&lt;/code&gt; for the majority of calls today — any call that wasn't conference-eligible. Which means every consumer that reads &lt;code&gt;conference_participant&lt;/code&gt; has &lt;em&gt;already&lt;/em&gt; been written to tolerate &lt;code&gt;null&lt;/code&gt;. You had to be null-safe from day one, or your Call Summary parser would have crashed on every non-conference call years ago.&lt;/p&gt;

&lt;p&gt;So the code is robust. It handles &lt;code&gt;null&lt;/code&gt; gracefully. And that robustness is exactly why August 1 slides past unnoticed: the day your &lt;em&gt;real&lt;/em&gt; conference-participation data flips to &lt;code&gt;null&lt;/code&gt; looks identical to every non-conference call you've been safely ignoring the whole time. There's no new error path to trip, because the &lt;code&gt;null&lt;/code&gt; path was load-bearing from the start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Call Summary today, for a conference-eligible call&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;"call_sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conference_participant"&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;"attributes"&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="c"&gt;/* participant data */&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;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// Same call, same request, on Aug 1 2026 — still 200 OK&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;"call_sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conference_participant"&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;"attributes"&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="c1"&gt;// &amp;lt;- your dashboard reads this as "no conference"&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 &lt;code&gt;if (summary.conference_participant?.attributes)&lt;/code&gt; guard you wrote to survive non-conference calls now silently classifies &lt;strong&gt;every&lt;/strong&gt; call as non-conference. Your conference metrics don't error. They trend to zero.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consumer&lt;/th&gt;
&lt;th&gt;Before Aug 1&lt;/th&gt;
&lt;th&gt;On/after Aug 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;REST: &lt;code&gt;GET /v1/Voice/{CallSid}/Summary&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;conference_participant.attributes&lt;/code&gt; populated for conference legs&lt;/td&gt;
&lt;td&gt;uniformly &lt;code&gt;null&lt;/code&gt;, 200 OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REST: &lt;code&gt;GET /v1/Voice/Summaries&lt;/code&gt; (list/scan jobs)&lt;/td&gt;
&lt;td&gt;conference legs carry attributes&lt;/td&gt;
&lt;td&gt;every row &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event Streams: Call Summary event&lt;/td&gt;
&lt;td&gt;event payload includes the field&lt;/td&gt;
&lt;td&gt;field present, value &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A dashboard that counts "conference participants seen"&lt;/td&gt;
&lt;td&gt;real number&lt;/td&gt;
&lt;td&gt;quietly decays to 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three failure shapes, all 200-clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Analytics that zero out.&lt;/strong&gt; A daily rollup of conference participation — average participants per conference, talk-time distribution, hold events — keeps running, keeps writing rows, and the numbers just slide toward zero starting August 1. Because the trend is gradual-looking (it's a step, but a step in a noisy metric reads as "quiet week"), nobody gets paged. Zeros don't alert; nulls don't alert. Only &lt;em&gt;errors&lt;/em&gt; alert, and there aren't any.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event Streams pipelines that dead-end silently.&lt;/strong&gt; If you sink Voice Insights Call Summary events into Kinesis / a webhook consumer / a warehouse and route on &lt;code&gt;conference_participant&lt;/code&gt;, the events keep arriving on schedule. The field keeps arriving too — as &lt;code&gt;null&lt;/code&gt;. There's no dead-letter, no schema-validation failure (the field still exists, it's just empty), no gap in the stream. The pipeline is perfectly healthy and perfectly empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Joins that go from sparse to empty.&lt;/strong&gt; A reconciliation job that enriches conference legs by joining Call Summary &lt;code&gt;conference_participant&lt;/code&gt; against your own conference records now matches nothing. If the join is a &lt;code&gt;LEFT JOIN&lt;/code&gt;, you get rows with null enrichment and no complaint. If downstream logic treats "no participant attributes" as "leg wasn't in a conference," you've just reclassified your entire conference history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The migration is not a rename
&lt;/h2&gt;

&lt;p&gt;This is the cost that makes it worth doing before the cliff, not after. &lt;code&gt;conference_participant&lt;/code&gt; is not being replaced by a differently-named field on the &lt;em&gt;same&lt;/em&gt; resource. The replacement is a &lt;strong&gt;different API with a different addressing model&lt;/strong&gt;: &lt;a href="https://www.twilio.com/docs/voice/voice-insights/conference-participant-summary" rel="noopener noreferrer"&gt;Conference Insights&lt;/a&gt; — the Conference Summary and Conference Participant Summary resources, plus Conference Insights Event Streams.&lt;/p&gt;

&lt;p&gt;The Call Summary is keyed by &lt;strong&gt;CallSid&lt;/strong&gt; — you look up a single call and read its (former) conference attributes inline. Conference Insights is keyed by &lt;strong&gt;ConferenceSid / ParticipantSid&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# Old (removed Aug 1) — conference data hung off the call
GET https://insights.twilio.com/v1/Voice/{CallSid}/Summary
    -&amp;gt; conference_participant.attributes

# New — conference data lives on the conference
GET https://insights.twilio.com/v1/Conferences/{ConferenceSid}
GET https://insights.twilio.com/v1/Conferences/{ConferenceSid}/Participants/{ParticipantSid}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the migration isn't "read a new key." It's: capture ConferenceSid/ParticipantSid at call time (or map CallSid → ConferenceSid), fan out to a second resource, and reshape your storage from call-centric to conference-centric. That's real work, and it's why the three-plus months of runway matters — the failure mode if you don't is not an exception you'll catch in staging, it's a metric that quietly goes to zero in prod.&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;# Any read of the field being removed (REST or SDK)&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;'conference_participant'&lt;/span&gt; src/

&lt;span class="c"&gt;# Event Streams / warehouse consumers routing on it&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;'conference_participant|conferenceParticipant'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.&lt;span class="o"&gt;{&lt;/span&gt;js,ts,py,go,java,sql,json&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Call Summary endpoints whose payloads carry it&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;'Voice/[^/]+/Summary|/Voice/Summaries|insights\.twilio\.com'&lt;/span&gt; src/

&lt;span class="c"&gt;# Null-guards that will now match every call (the silent reclassifier)&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;'conference_participant.{0,20}(attributes|null|\?\.|&amp;amp;&amp;amp;)'&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-grep audit: anything wired through a no-code connector (a Zapier/Make/n8n step, a warehouse sync, a BI tool pulling Voice Insights) that references &lt;code&gt;conference_participant&lt;/code&gt; won't show up in your repo at all — check those pipelines by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who this actually hits — and who it doesn't
&lt;/h2&gt;

&lt;p&gt;Be honest about the blast radius, because it's narrower than most silent-drift stories. If you don't run Twilio &lt;strong&gt;conferences&lt;/strong&gt;, this is a non-event — the field was &lt;code&gt;null&lt;/code&gt; for you already and stays &lt;code&gt;null&lt;/code&gt;. If you already migrated to Conference Insights, you're done. The population in the crosshairs is specific: teams doing &lt;strong&gt;conference-call analytics on Voice Insights&lt;/strong&gt; who still read &lt;code&gt;conference_participant&lt;/code&gt; — contact centers, CPaaS-built collaboration tools, compliance/QA recording pipelines that score multi-party calls.&lt;/p&gt;

&lt;p&gt;But that's exactly the population least likely to notice, for the same reason every entry in this table is 200 OK: the people who built conference analytics on this field built it years ago, wrapped it in a null guard because they had to, and haven't looked at that code since it started working. The August 1 removal is invisible to precisely the code most affected by it.&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 that used to carry data starts returning &lt;code&gt;null&lt;/code&gt;, when a 200 stops meaning what it meant last week, or when a value your dashboards read every night quietly empties out. Twilio's &lt;code&gt;conference_participant&lt;/code&gt; removal is the textbook case: no error, no status-code change, no schema break — just data that stops arriving under a healthcheck that stays green. &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;flarecanary.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>twilio</category>
      <category>api</category>
      <category>integrations</category>
      <category>devops</category>
    </item>
    <item>
      <title>Greenhouse Harvest v1/v2 retires August 31 — three silent failure modes during your v3 cursor pagination migration</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 28 Jul 2026 05:00:48 +0000</pubDate>
      <link>https://dev.to/flarecanary/greenhouse-harvest-v1v2-retires-august-31-three-silent-failure-modes-during-your-v3-cursor-6cl</link>
      <guid>https://dev.to/flarecanary/greenhouse-harvest-v1v2-retires-august-31-three-silent-failure-modes-during-your-v3-cursor-6cl</guid>
      <description>&lt;p&gt;If you have an ATS integration that reads Greenhouse data — a custom recruiter dashboard, a Snowflake warehouse pulling candidate history, a Slack bot that posts interview scorecards, anything piped through the Harvest API — you have nine weeks. On &lt;strong&gt;August 31, 2026&lt;/strong&gt;, Greenhouse removes Harvest API &lt;strong&gt;v1 and v2&lt;/strong&gt; entirely. Every integration must be on &lt;strong&gt;v3&lt;/strong&gt; by that date.&lt;/p&gt;

&lt;p&gt;The Greenhouse &lt;a href="https://developers.greenhouse.io/harvest.html" rel="noopener noreferrer"&gt;Harvest landing page&lt;/a&gt; puts it plainly: &lt;em&gt;"The Harvest v1/v2 API is deprecated and will be removed on August 31, 2026. Please migrate to Harvest v3."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The loud failure mode after the cutoff is the one teams plan around. v1/v2 endpoints stop accepting requests, you get a 4xx, you redeploy with v3.&lt;/p&gt;

&lt;p&gt;The failure modes worth thinking about live in the migration window — the next nine weeks, while v1/v2 and v3 both work and integrations are partly converted. Three of them swallow data without raising an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The v3 changes that matter for silent drift
&lt;/h2&gt;

&lt;p&gt;Before the failure modes, the v3 shape. Three things changed that touch every paginated endpoint:&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;v1 / v2&lt;/th&gt;
&lt;th&gt;v3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;Basic Auth (API token as username, blank password, Base64-encoded)&lt;/td&gt;
&lt;td&gt;OAuth 2.0 bearer token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pagination&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Link&lt;/code&gt; header (RFC 5988) with &lt;code&gt;rel="next"&lt;/code&gt;, &lt;code&gt;rel="prev"&lt;/code&gt;, &lt;code&gt;rel="last"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Link&lt;/code&gt; header with &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;rel="next"&lt;/code&gt;, cursor must be the &lt;strong&gt;only&lt;/strong&gt; query parameter on subsequent requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limiting&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-RateLimit-Limit&lt;/code&gt; over a &lt;strong&gt;10-second&lt;/strong&gt; window&lt;/td&gt;
&lt;td&gt;The same headers over a longer fixed window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pagination row is the one the v3 docs are most emphatic about. From the &lt;a href="https://harvestdocs.greenhouse.io/docs/pagination" rel="noopener noreferrer"&gt;Harvest v3 pagination guide&lt;/a&gt;: &lt;em&gt;"When you pass a cursor, it must be the only query parameter."&lt;/em&gt; And: &lt;em&gt;"Treat the cursor as an opaque value: don't parse it, and don't try to construct it yourself."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both of those are sharp. Both of them break in ways the migration team is going to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The "cursor + filter → 422 → end of pages" silent truncation
&lt;/h2&gt;

&lt;p&gt;The most common shape of a v1/v2 paginated extract:&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;url&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;/v1/applications?job_id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;per_page=500&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;url&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="n"&gt;url&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="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;rows&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_link_header&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="n"&gt;headers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Link&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the structure of the loop: the first request carries the filters (&lt;code&gt;job_id&lt;/code&gt;, &lt;code&gt;per_page&lt;/code&gt;, &lt;code&gt;created_after&lt;/code&gt;, whatever). The subsequent requests follow the &lt;code&gt;Link: &amp;lt;...&amp;gt;; rel="next"&lt;/code&gt; URL the server hands back. In v1/v2, that URL has all the filter parameters baked in. The loop stops when there is no &lt;code&gt;next&lt;/code&gt; link.&lt;/p&gt;

&lt;p&gt;A migration that converts the first request to v3 but forgets that v3's cursor rule is "cursor must be the only query parameter" produces this:&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v3/applications?job_id=123&amp;amp;per_page=500&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# first request
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;url&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="n"&gt;url&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="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                      &lt;span class="c1"&gt;# &amp;lt;-- the problem
&lt;/span&gt;    &lt;span class="n"&gt;rows&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_link_header&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="n"&gt;headers&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Link&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request succeeds and returns a page of applications for &lt;code&gt;job_id=123&lt;/code&gt; plus a &lt;code&gt;Link&lt;/code&gt; header with a cursor URL. Greenhouse's cursor URL does &lt;strong&gt;not&lt;/strong&gt; include &lt;code&gt;job_id=123&lt;/code&gt; — the cursor encodes the position internally. The migration team, looking at this from a code-review distance, doesn't notice that &lt;code&gt;job_id&lt;/code&gt; has dropped.&lt;/p&gt;

&lt;p&gt;The next iteration follows the cursor URL. v3 accepts it. The pages keep coming. The pipeline ingests applications from &lt;code&gt;job_id=123&lt;/code&gt;, then applications from &lt;code&gt;job_id=124&lt;/code&gt;, then &lt;code&gt;125&lt;/code&gt;, and so on — every application in the tenant, not just the one job the original query asked for.&lt;/p&gt;

&lt;p&gt;That's the version where the loop doesn't even raise. The version that does raise looks like this — the migration team appends the cursor to a URL builder that still adds tenant-default query parameters:&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v3/applications&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cursor_from_link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;per_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Greenhouse v3 returns &lt;strong&gt;422 Unprocessable Content&lt;/strong&gt; because there are two query parameters and &lt;code&gt;cursor&lt;/code&gt; must be alone. A pipeline whose retry logic treats 4xx as "permanent — stop paginating" silently completes with whatever rows it got from page 1.&lt;/p&gt;

&lt;p&gt;There's no error in your warehouse. Your row counts are lower than yesterday's, but day-over-day variance on ATS data is normal and nobody alerts on a 30% drop on a Tuesday. The integration's healthcheck shows green because the last call returned a response. The deferred symptom is "our recruiting funnel dashboard has been wrong for two weeks."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The &lt;code&gt;prev&lt;/code&gt; and &lt;code&gt;last&lt;/code&gt; Link rels are gone
&lt;/h2&gt;

&lt;p&gt;v3 returns &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;rel="next"&lt;/code&gt;. The official guide: &lt;em&gt;"Harvest v3 currently returns only a next link (no prev or last)."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For most batch ETL, this doesn't matter — you walked forward, you stopped at the end. For interactive consumers, it matters a lot.&lt;/p&gt;

&lt;p&gt;The patterns that break silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Jump to last page"&lt;/strong&gt; controls in admin UIs reading the &lt;code&gt;rel="last"&lt;/code&gt; link and using its cursor. The &lt;code&gt;Link&lt;/code&gt; header lacks that rel; a permissive HTTP client returns &lt;code&gt;None&lt;/code&gt; rather than raising; the UI button stops working. Most ATS admin tools route through a generic API gateway that swallows missing rels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Page back"&lt;/strong&gt; controls that walked &lt;code&gt;rel="prev"&lt;/code&gt;. Same story — silently dead, no error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total-count estimates&lt;/strong&gt; built by following &lt;code&gt;rel="last"&lt;/code&gt; and reading the page number from the URL. The page number isn't in the URL anyway (v3 uses opaque cursors), so this was already going to break, but now it breaks &lt;em&gt;before&lt;/em&gt; the URL parser gets a chance to fail — the rel just isn't there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your codebase has any of &lt;code&gt;links["prev"]&lt;/code&gt;, &lt;code&gt;links["last"]&lt;/code&gt;, or a &lt;code&gt;parse_link_header&lt;/code&gt; call that doesn't assert which rels it got, those callsites need an audit before September 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cursor parsing for "where am I" silently desyncs
&lt;/h2&gt;

&lt;p&gt;The v3 cursor is opaque. The docs are direct: &lt;em&gt;"don't parse it, and don't try to construct it yourself."&lt;/em&gt; In practice it's a Base64-encoded value that encodes a primary-key position — Harvest paginates by &lt;code&gt;id&lt;/code&gt; descending.&lt;/p&gt;

&lt;p&gt;Two patterns that break:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "resume from yesterday" cron.&lt;/strong&gt; Long-running ETL pipelines often persist the last cursor they consumed and resume from that cursor the next day. v1/v2 had stable, predictable next-URLs that were safe to store. v3's cursor is opaque, and Greenhouse hasn't published a stability guarantee — a cursor stored today may produce &lt;strong&gt;422 Unprocessable Content&lt;/strong&gt; tomorrow if the cursor format changes, or it may silently produce data from a different position if the encoding's interpretation drifts. The safer pattern is to store a high-water mark by &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;updated_at&lt;/code&gt; and re-paginate from there each run, not to persist cursors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "skip to row 10,000" debug helper.&lt;/strong&gt; Engineers who built tools that take a cursor as a CLI flag and parse it to print "you're at position X" silently degrade to "you're at position [unparseable]." Worse, helpers that construct a synthetic cursor (e.g., to start partway through a sync) hit a 422 and either crash or get silently treated as "no more data."&lt;/p&gt;

&lt;p&gt;Anyone writing v3-compatible code should treat the cursor exactly like the docs say — a token to be passed back, never inspected. Code that diverges from this rule is the first thing to grep for during the migration audit.&lt;/p&gt;

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

&lt;p&gt;Four targeted searches that catch the bulk of v1/v2-shaped code in a typical Harvest integration:&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="c"&gt;# Direct version pins&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;'harvest\.greenhouse\.io/v[12]|api/v[12]/(candidates|applications|jobs|offers)'&lt;/span&gt; src/

&lt;span class="c"&gt;# Basic Auth that won't carry to v3&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;'Authorization: Basic|base64.*api[_-]?key.*:'&lt;/span&gt; src/ scripts/

&lt;span class="c"&gt;# Link header parsers that read rels v3 doesn't return&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;'rel=["\x27]?(prev|last)|links\[["\x27](prev|last)'&lt;/span&gt; src/

&lt;span class="c"&gt;# Cursor parsing or construction&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;'base64.*decode.*cursor|cursor\s*=\s*[fF]"|f["\x27]\?cursor='&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-grep audit is the persisted-config check: any Airbyte connector, Fivetran connector, Workato recipe, or n8n workflow with &lt;code&gt;harvest.greenhouse.io/v1&lt;/code&gt; or &lt;code&gt;/v2&lt;/code&gt; in its config string needs to be re-pointed before August 31, and any stored cursors flushed before the version flip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this lands silently when v3 is &lt;em&gt;more&lt;/em&gt; strict, not less
&lt;/h2&gt;

&lt;p&gt;The strict-pagination rule (&lt;code&gt;cursor&lt;/code&gt; must be alone) is exactly the kind of contract change that produces silent failure when error handling is generic. A 200-OK with the wrong rows fails open. A 422 from a pipeline that treats 4xx as "stop, no more data" also fails open. The only loud failure is "the v1 endpoint is gone" — and that's the one that doesn't fire until September 1.&lt;/p&gt;

&lt;p&gt;The window from now to August 31 is when the wrong code is most likely to ship: v3 endpoints work, v1/v2 endpoints work, and the migration looks "done" because all the tests pass and the dashboards have data. The data is just from the wrong rows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FlareCanary watches the response shapes of API endpoints you depend on and tells you when filter parameters silently drop, pagination links vanish from headers, or row counts shift in ways that look normal but aren't. Greenhouse's v3 migration is exactly the kind of transition where a passing healthcheck and a clean Sentry feed 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>api</category>
      <category>integrations</category>
      <category>hr</category>
      <category>devops</category>
    </item>
    <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>
  </channel>
</rss>
