<?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.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>Your Shopify discount is in the admin but missing from the API — the 2026-07 market-eligibility trap</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 26 May 2026 05:01:05 +0000</pubDate>
      <link>https://dev.to/flarecanary/your-shopify-discount-is-in-the-admin-but-missing-from-the-api-the-2026-07-market-eligibility-trap-1db2</link>
      <guid>https://dev.to/flarecanary/your-shopify-discount-is-in-the-admin-but-missing-from-the-api-the-2026-07-market-eligibility-trap-1db2</guid>
      <description>&lt;p&gt;Shopify shipped market eligibility for discounts in the &lt;strong&gt;2026-07&lt;/strong&gt; Admin API (announced on the changelog as "Assign discounts to specific markets," May 7, 2026). Merchants can now scope a code or automatic discount to specific markets — region, B2B company location, retail location.&lt;/p&gt;

&lt;p&gt;That's the feature. Here's the part that doesn't show up in a release-note skim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you query discounts on an API version &lt;strong&gt;prior to 2026-07&lt;/strong&gt;, any discount that has market eligibility set is &lt;strong&gt;filtered out&lt;/strong&gt; of the response — because older versions can't represent it. This applies to both list queries and fetching a specific discount by ID.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No error. No &lt;code&gt;userErrors&lt;/code&gt;. No deprecation header. The discount is simply not in the payload. And unlike most breaking changes, this one has no countdown — it is &lt;strong&gt;already live&lt;/strong&gt; for any merchant who has touched the new market-eligibility selector in their admin (it's available on Basic plans and up). If your app is pinned to &lt;code&gt;2025-10&lt;/code&gt; or &lt;code&gt;2026-01&lt;/code&gt;, you may be returning incomplete discount data to your users right now.&lt;/p&gt;

&lt;p&gt;Here is the silent-fail surface we keep seeing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A clean 200 with the discount missing
&lt;/h2&gt;

&lt;p&gt;The app queries discounts on its pinned version:&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="c"&gt;# App pinned to API version 2026-01&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&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;discountNodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;nodes&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;discount&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="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DiscountCodeBasic&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;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&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="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DiscountAutomaticBasic&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;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&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;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 merchant has 12 active discounts. Three of them are scoped to the "North America" market. The query returns &lt;strong&gt;9 nodes&lt;/strong&gt;, HTTP 200, no &lt;code&gt;userErrors&lt;/code&gt;, no extensions warning. Nothing in the response indicates three discounts were withheld.&lt;/p&gt;

&lt;p&gt;There is no schema diff to catch at code review. The query is valid on both versions. The field set is identical. The only difference is which rows come back — and that's data, not schema, so static analysis and SDK type-checking won't flag it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Fetching by ID returns &lt;code&gt;null&lt;/code&gt; — which reads as "deleted"
&lt;/h2&gt;

&lt;p&gt;This is the dangerous one.&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;discountNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;"gid://shopify/DiscountAutomaticNode/1099876"&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="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 discount &lt;code&gt;1099876&lt;/code&gt; has market eligibility set and you're on a pre-2026-07 version, this returns &lt;code&gt;null&lt;/code&gt;. Same as a discount that was deleted. Same as an ID that never existed.&lt;/p&gt;

&lt;p&gt;A lot of integration code treats "I asked for this ID and got null" as a tombstone:&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;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shopify_gid&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;node&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;local_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# discount removed upstream — clean up
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code now deletes a live discount from the app's own store the first time the merchant adds a market restriction to it. The discount is still working in the merchant's storefront. The app just garbage-collected its own copy because a version-filtered read looked like a deletion. This is silent data loss inside the integration, not just a missing read.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Sync, audit, and reconciliation jobs undercount with no signal
&lt;/h2&gt;

&lt;p&gt;Anything that enumerates discounts is now working from a partial set on old versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Loyalty / promo-management apps that mirror discounts&lt;/li&gt;
&lt;li&gt;"Export all active discounts" / reporting jobs&lt;/li&gt;
&lt;li&gt;Discount-conflict checkers ("does this new code overlap an existing one?")&lt;/li&gt;
&lt;li&gt;Reconciliation jobs that compare app state to Shopify state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They all get a 200 and a short list. The conflict checker clears a code that actually collides. The reconciliation job "heals" a discount it can't see by recreating or deleting it. The audit dashboard shows 9 active promos when there are 12. Every one of these fails toward &lt;em&gt;looks correct&lt;/em&gt; — the worst kind, because no alert fires.&lt;/p&gt;

&lt;p&gt;The merchant's symptom is the giveaway, and it's the exact phrase people are about to start Googling: &lt;strong&gt;"my discount shows in the Shopify admin but is missing from the API."&lt;/strong&gt; It's not a bug in your sync. It's the version filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Bulk operations and anything sharing the query layer inherit the gap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bulkOperationRunQuery&lt;/code&gt; executes a GraphQL query at &lt;em&gt;your app's configured API version&lt;/em&gt;. A nightly bulk export over &lt;code&gt;discountNodes&lt;/code&gt; is exactly the "list everything" job most likely to drive downstream reporting — and it silently drops the same market-eligible discounts. Because bulk results land as a JSONL file you parse later, there's even less chance anyone notices the count is short.&lt;/p&gt;

&lt;p&gt;If you have a webhook-driven discount cache, sanity-check what your subscription's API version actually represents before trusting it as complete. The reliable mental model: on a pre-2026-07 version, &lt;em&gt;any&lt;/em&gt; read path that could return a market-eligible discount returns it filtered out instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. After you upgrade, the inheritance rules are their own trap
&lt;/h2&gt;

&lt;p&gt;Moving to 2026-07 fixes the disappearing-rows problem but introduces a correctness one if you write migration code that maps discounts to markets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discounts &lt;strong&gt;do not inherit across market types.&lt;/strong&gt; A discount assigned to a regional market does not automatically cover a B2B company location or a retail location — those are separate market types.&lt;/li&gt;
&lt;li&gt;Within a type, a regional assignment &lt;strong&gt;does&lt;/strong&gt; cascade to sub-markets: assign to "North America" and it applies to "Canada" automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Migration scripts that "assign this discount to all markets" by enumerating leaf markets and looping will either over-apply (re-adding sub-markets that already inherit) or under-apply (missing other market types entirely). Model the assignment against the type/inheritance rules, not a flat list of market IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade your Admin API version to &lt;code&gt;2026-07&lt;/code&gt; (or later)&lt;/strong&gt; before treating any discount read as complete. This is the only real fix — there is no flag to opt the old version into representing market eligibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Until you've upgraded, treat discount reads from older versions as potentially incomplete, not authoritative.&lt;/strong&gt; Specifically: do not drive deletes, tombstoning, or reconciliation off a &lt;code&gt;null&lt;/code&gt;/missing discount from a pre-2026-07 version. A "not found" on an old version is ambiguous — it could be a market-eligible discount, not a deletion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect your exposure with a version diff.&lt;/strong&gt; Run the same &lt;code&gt;discountNodes&lt;/code&gt; count against the same shop on your current version and on &lt;code&gt;2026-07&lt;/code&gt;. Any delta is the set of market-eligible discounts you were blind to. That number is also the size of your reconciliation-job error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On 2026-07, filter market intentionally.&lt;/strong&gt; Use the market context / &lt;code&gt;market_ids&lt;/code&gt; argument on &lt;code&gt;discountNodes&lt;/code&gt; rather than assuming the unfiltered list is global, and encode the type/sub-market inheritance rules before mapping discounts to markets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason this one is worth acting on now rather than at a cutoff date: there is no cutoff. The filter is active the moment a merchant uses a feature Shopify has already shipped to every Basic-and-up store. The gap between "discount visible in admin" and "discount absent from API" opens silently, on the merchant's schedule, not yours — and the only signal is a row count that looks plausible.&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 third-party APIs and SDKs for breaking changes like this one — including version-gated response filtering that returns a clean 200 with rows quietly removed — and surfaces them before they hit production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>graphql</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Claude Opus 4 and Sonnet 4 retire June 15 — your `claude-opus-4-0` alias is about to start failing</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 23 May 2026 05:01:00 +0000</pubDate>
      <link>https://dev.to/flarecanary/claude-opus-4-and-sonnet-4-retire-june-15-your-claude-opus-4-0-alias-is-about-to-start-failing-ej5</link>
      <guid>https://dev.to/flarecanary/claude-opus-4-and-sonnet-4-retire-june-15-your-claude-opus-4-0-alias-is-about-to-start-failing-ej5</guid>
      <description>&lt;p&gt;On April 14, 2026, Anthropic deprecated &lt;code&gt;claude-opus-4-20250514&lt;/code&gt; and &lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt;. Both models retire on &lt;strong&gt;June 15, 2026&lt;/strong&gt; — about four weeks from today. After that, the Anthropic API returns errors for those snapshot IDs.&lt;/p&gt;

&lt;p&gt;Most teams reading this already know that. What they often don't know is what &lt;em&gt;else&lt;/em&gt; breaks on June 15, because Claude 4 is more entangled in production code than just one model ID.&lt;/p&gt;

&lt;p&gt;Here is the silent-fail surface we keep seeing on review:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The &lt;code&gt;claude-opus-4-0&lt;/code&gt; and &lt;code&gt;claude-sonnet-4-0&lt;/code&gt; aliases retire with the snapshot
&lt;/h2&gt;

&lt;p&gt;Anthropic exposes versioned aliases like &lt;code&gt;claude-opus-4-0&lt;/code&gt; and &lt;code&gt;claude-sonnet-4-0&lt;/code&gt; so callers don't have to track datestamp suffixes. The aliases are convenient — until the underlying snapshot is the one being retired.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;claude-opus-4-0&lt;/code&gt; resolves to &lt;code&gt;claude-opus-4-20250514&lt;/code&gt;. That is the snapshot being retired. On June 15, the alias breaks too.&lt;/p&gt;

&lt;p&gt;This catches teams that did due-diligence audits by searching their codebase for &lt;code&gt;20250514&lt;/code&gt;. The snapshot ID isn't in the code — the alias is. Grep both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"claude-(opus|sonnet)-4-0&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="s2"&gt;|claude-(opus|sonnet)-4-20250514"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find &lt;code&gt;-4-0&lt;/code&gt; references, those calls fail on June 15 the same as the explicit snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Don't confuse &lt;code&gt;4-0&lt;/code&gt; with &lt;code&gt;4-1&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The Anthropic deprecation table lists only &lt;code&gt;claude-opus-4-20250514&lt;/code&gt; as retiring on June 15. &lt;code&gt;claude-opus-4-1-20250805&lt;/code&gt; — Opus 4.1 — is still active, retirement "not sooner than August 5, 2026."&lt;/p&gt;

&lt;p&gt;Reading that quickly invites a wrong conclusion: "we're on Opus 4-point-something, we're fine."&lt;/p&gt;

&lt;p&gt;You aren't. The retirement is specific to the bare 4 release. If your code says &lt;code&gt;claude-opus-4-0&lt;/code&gt;, you're not on 4.1, you're on the model that retires next month.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Bedrock and Vertex AI run their own schedule
&lt;/h2&gt;

&lt;p&gt;The retirement dates Anthropic publishes apply to the Anthropic API, the Claude Platform on AWS, and Microsoft Foundry. Partner-operated platforms — Amazon Bedrock and Vertex AI — set their own schedules.&lt;/p&gt;

&lt;p&gt;In practice, the same alias may keep working on Bedrock past June 15 while failing against the Anthropic API. We've seen this fail one specific way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local dev + CI test against a Bedrock endpoint, all green.&lt;/li&gt;
&lt;li&gt;Prod calls the Anthropic API direct, starts erroring.&lt;/li&gt;
&lt;li&gt;The error doesn't reproduce in the dev environment because Bedrock's &lt;code&gt;anthropic.claude-opus-4-v1:0&lt;/code&gt; model identifier hasn't retired yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run a multi-cloud Anthropic stack, check the Bedrock and Vertex AI tables separately. Don't infer one from the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Mocked SDK tests will not catch this
&lt;/h2&gt;

&lt;p&gt;A common test pattern:&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="nd"&gt;@patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic.Anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_summarizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mock_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&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="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mock doesn't care which model string you pass. The string &lt;code&gt;"claude-opus-4-0"&lt;/code&gt; is just a parameter the mock ignores. The test stays green forever — including the moment after the model is retired in production.&lt;/p&gt;

&lt;p&gt;The fix is to push at least one integration test (gated on an API key in CI) against the live Anthropic API for each model ID your code references. If the model is retired, that test goes red. If it's mocked-only, you find out from a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Model-router fallback configs go silent
&lt;/h2&gt;

&lt;p&gt;Teams that built model-router fallbacks during the 2025 outages have configs that look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-opus-4-7&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;claude-opus-4-0&lt;/span&gt;       &lt;span class="c1"&gt;# &amp;lt;-- retires June 15&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;claude-opus-3&lt;/span&gt;
  &lt;span class="na"&gt;on_error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate_limit"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_unavailable"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is "if 4.7 is rate-limited or unavailable, fall back to a different model." After June 15, the fallback list contains a retired model. Routing through to the fallback now produces a hard error instead of degraded service. Worse: most routers count the fallback attempt as a successful retry and never escalate.&lt;/p&gt;

&lt;p&gt;Audit your router configs alongside your direct call sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Evals and comparison harnesses lose their baseline
&lt;/h2&gt;

&lt;p&gt;If your team runs comparative evals against fixed model versions — common for regression testing prompt changes — you have a hardcoded &lt;code&gt;claude-opus-4-20250514&lt;/code&gt; somewhere in the eval harness. On June 15 that branch of the eval errors out. Most eval runners are written to mark errors as "skip," not "fail," because the assumption was the error is transient. After June 15, the skip is permanent and you've quietly stopped measuring against that baseline.&lt;/p&gt;

&lt;p&gt;Capture a final round of baseline scores before June 15, snapshot the outputs, and switch the eval target to a still-active model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Anthropic's recommended replacements (from the official deprecation table):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Retiring&lt;/th&gt;
&lt;th&gt;Recommended replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude-opus-4-20250514&lt;/code&gt; (&lt;code&gt;claude-opus-4-0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-opus-4-7&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;claude-sonnet-4-20250514&lt;/code&gt; (&lt;code&gt;claude-sonnet-4-0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4-6&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;API format, auth, and response structure are unchanged — only the model identifier needs to update. Pricing and capabilities differ from 4.0; do not assume the swap is cost-neutral.&lt;/p&gt;

&lt;p&gt;If you're already doing the migration work, &lt;code&gt;claude-opus-4-7&lt;/code&gt; is the current top-of-line and worth jumping to rather than stopping at 4.5.&lt;/p&gt;

&lt;h2&gt;
  
  
  A clean migration checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git grep -E "claude-(opus|sonnet)-4-0\b|claude-(opus|sonnet)-4-20250514"&lt;/code&gt; across all repos, including infra-as-code, config files, prompts, and eval harnesses.&lt;/li&gt;
&lt;li&gt;Check Bedrock and Vertex AI configs separately if you use them.&lt;/li&gt;
&lt;li&gt;Audit model-router fallback chains for any 4.0 references.&lt;/li&gt;
&lt;li&gt;Add at least one integration test per model ID that hits the live Anthropic API, gated on a CI secret. Mocked tests do not catch retirement.&lt;/li&gt;
&lt;li&gt;Capture a final baseline of any comparative evals against the retiring models before June 15. Snapshot the outputs.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;claude-opus-4-0&lt;/code&gt; → &lt;code&gt;claude-opus-4-7&lt;/code&gt;, &lt;code&gt;claude-sonnet-4-0&lt;/code&gt; → &lt;code&gt;claude-sonnet-4-6&lt;/code&gt;. Test response shape and cost in staging.&lt;/li&gt;
&lt;li&gt;Re-run evals on the replacement to confirm acceptable quality on your tasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fact that the API format is unchanged is what makes this dangerous. There is no schema diff to spot at code-review time. The only signal you'll get is the request failing in production at 12:00 UTC on June 15 — unless you go looking for the alias now.&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 third-party APIs and SDKs for breaking changes like this one — including model retirements and alias remappings — and surfaces them before they hit production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>anthropic</category>
      <category>llm</category>
      <category>deprecation</category>
    </item>
    <item>
      <title>xAI retired 8 Grok models on May 15 — the slugs still resolve, so your bill and output quality changed silently</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 20 May 2026 05:00:38 +0000</pubDate>
      <link>https://dev.to/flarecanary/xai-retired-8-grok-models-on-may-15-the-slugs-still-resolve-so-your-bill-and-output-quality-26jd</link>
      <guid>https://dev.to/flarecanary/xai-retired-8-grok-models-on-may-15-the-slugs-still-resolve-so-your-bill-and-output-quality-26jd</guid>
      <description>&lt;p&gt;On &lt;strong&gt;May 15, 2026 at 12:00 PM PT&lt;/strong&gt;, xAI retired eight model slugs from the Grok API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;grok-4-1-fast-reasoning&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-4-1-fast-non-reasoning&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-4-fast-reasoning&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-4-fast-non-reasoning&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-4-0709&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-code-fast-1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;grok-imagine-image-pro&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the line from xAI's migration notice that makes this dangerous:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The slugs themselves continue to resolve, so you do not need to change your code to avoid breakage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sounds reassuring. It is the opposite of reassuring. "You do not need to change your code" is exactly why most teams &lt;em&gt;didn't&lt;/em&gt; — and a retirement that requires no code change is a retirement that ships no signal. Nothing 404s. No SDK exception. No deploy. The same request you sent on May 14 still returns &lt;code&gt;200&lt;/code&gt; on May 16. What changed is underneath the slug, and none of the usual alarms are wired to it.&lt;/p&gt;

&lt;p&gt;Here is the silent-fail surface we keep seeing on review.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;grok-code-fast-1&lt;/code&gt; now bills at grok-4.3 rates — and that's your highest-volume slug
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-code-fast-1&lt;/code&gt; was xAI's cheap, fast, coding-optimized model. Its entire reason to exist was running a lot of tokens for a little money — agentic coding loops, refactor passes, repo-wide edits, autocomplete backends. High call volume, low unit price. That's the slug people deliberately picked &lt;em&gt;because&lt;/em&gt; it was cheap.&lt;/p&gt;

&lt;p&gt;After May 15, requests to &lt;code&gt;grok-code-fast-1&lt;/code&gt; redirect to &lt;code&gt;grok-4.3&lt;/code&gt;, billed at grok-4.3's rate of &lt;strong&gt;$1.25 per 1M input tokens and $2.50 per 1M output tokens&lt;/strong&gt; — flagship pricing, not the fast-tier pricing you chose. The redirect is the worst possible combination: it lands hardest on the slug with the highest token throughput, and it produces no error, no warning, no changed status code. The first signal is the invoice, and the invoice arrives weeks late.&lt;/p&gt;

&lt;p&gt;If you run agentic coding on Grok, this is not a "review next sprint" item. Your cost per run changed on May 15 and your monitoring almost certainly didn't notice, because cost-per-token isn't something most teams alert on until finance asks a question.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The reasoning slugs are now answering at &lt;code&gt;low&lt;/code&gt; effort
&lt;/h2&gt;

&lt;p&gt;The redirect is not a clean one-to-one swap. xAI maps the retired slugs onto grok-4.3 with a &lt;em&gt;reduced&lt;/em&gt; reasoning setting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every retired &lt;strong&gt;reasoning&lt;/strong&gt; slug (&lt;code&gt;grok-4-fast-reasoning&lt;/code&gt;, &lt;code&gt;grok-4-1-fast-reasoning&lt;/code&gt;) → &lt;code&gt;grok-4.3&lt;/code&gt; with &lt;strong&gt;&lt;code&gt;low&lt;/code&gt;&lt;/strong&gt; reasoning effort.&lt;/li&gt;
&lt;li&gt;Every retired &lt;strong&gt;non-reasoning&lt;/strong&gt; slug → &lt;code&gt;grok-4.3&lt;/code&gt; with &lt;strong&gt;&lt;code&gt;none&lt;/code&gt;&lt;/strong&gt; reasoning effort.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you picked &lt;code&gt;grok-4-fast-reasoning&lt;/code&gt; specifically because a task needed the model to think — structured extraction, multi-step tool planning, anything where you traded latency for correctness — you are now getting &lt;code&gt;low&lt;/code&gt; effort by default. The model still answers. The answer is still well-formed JSON, still parses, still passes your schema validation. It's just measurably worse on the hard cases, and there is no field in the response that says "I thought less about this than I used to." Your eval suite is the only thing that would catch it, and only if you re-ran it after May 15 — which nobody schedules, because nothing told them to.&lt;/p&gt;

&lt;p&gt;This is the textbook drift shape: a valid-looking response that is a correct answer to a &lt;em&gt;different question&lt;/em&gt; than the one your code thinks it asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Cost-attribution dashboards now lie
&lt;/h2&gt;

&lt;p&gt;A lot of teams tag spend by the model slug they send: a &lt;code&gt;model&lt;/code&gt; dimension on a metrics counter, a column in a usage table, a group-by in the monthly cost rollup. Those dashboards key off &lt;em&gt;the string you sent&lt;/em&gt;, not the model that actually ran.&lt;/p&gt;

&lt;p&gt;Post-May-15, your dashboard still shows a tidy line item for &lt;code&gt;grok-code-fast-1&lt;/code&gt; at the old unit price in your own math — while xAI bills the account at grok-4.3 rates. Internal cost attribution and the actual bill have silently diverged. Every "cost per feature" or "margin per customer" number that flows from that slug is now wrong, and it will stay wrong until someone reconciles the xAI invoice against the dashboard by hand and notices the totals don't match.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;grok-imagine-image-pro&lt;/code&gt; is a different image model now
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-imagine-image-pro&lt;/code&gt; redirects to &lt;code&gt;grok-imagine-image-quality&lt;/code&gt;. That is a different image model, not a renamed one. Anything downstream that made assumptions about the old model's output — dimensions, style, latency budget, cost per image, safety-filter behavior — is now feeding a different generator into the same pipeline with no version bump. Image pipelines are especially exposed here because the output "looks fine" to code; only a human comparing before/after notices the model changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Fallback chains lost their cheap degraded mode
&lt;/h2&gt;

&lt;p&gt;Routers built during past provider incidents tend to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grok-4.3&lt;/span&gt;
&lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grok-4-fast-non-reasoning&lt;/span&gt;   &lt;span class="c1"&gt;# cheap degraded mode&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grok-3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent was: if the primary is rate-limited or down, drop to a cheaper model and keep serving. After May 15 both fallback entries resolve to &lt;code&gt;grok-4.3&lt;/code&gt;. The "cheap degraded mode" is now full-price grok-4.3 — so the exact moment you fail over under load is the exact moment your per-request cost jumps to flagship rates, with no error and no log line saying the cheap path is gone. Incident plus silent cost blowout, stacked.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pinned eval baselines now track a moving target
&lt;/h2&gt;

&lt;p&gt;If you run regression evals against a fixed model slug — standard practice for catching prompt regressions — you have &lt;code&gt;grok-4-fast-reasoning&lt;/code&gt; or similar hardcoded in the harness. That pin was the whole point: a stable baseline to diff prompt changes against.&lt;/p&gt;

&lt;p&gt;After May 15 the pin resolves to &lt;code&gt;grok-4.3&lt;/code&gt; at &lt;code&gt;low&lt;/code&gt; effort. Your "stable baseline" moved. Every prompt-change diff you run against it from now on is measuring two variables at once — your prompt edit &lt;em&gt;and&lt;/em&gt; a model swap you didn't make — and the harness has no idea, because the slug string in the config is unchanged.&lt;/p&gt;

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

&lt;p&gt;The migration itself is small. The detection is the hard part, because there is no schema diff to catch at review time and no error to alert on.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep every repo, IaC file, notebook, and prompt config&lt;/strong&gt; for the retired slugs:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"grok-(4-1-fast-(reasoning|non-reasoning)|4-fast-(reasoning|non-reasoning)|4-0709|code-fast-1|3|imagine-image-pro)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include eval harnesses, fallback/router configs, and cost-attribution code — not just your main call sites. Those three are where this hides.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pin &lt;code&gt;grok-4.3&lt;/code&gt; explicitly and choose your reasoning effort.&lt;/strong&gt; Don't keep riding the redirect. The redirect picks &lt;code&gt;low&lt;/code&gt;/&lt;code&gt;none&lt;/code&gt; for you; only an explicit &lt;code&gt;grok-4.3&lt;/code&gt; call with an explicit effort level (&lt;code&gt;none&lt;/code&gt;/&lt;code&gt;low&lt;/code&gt;/&lt;code&gt;medium&lt;/code&gt;/&lt;code&gt;high&lt;/code&gt;) puts the quality/cost tradeoff back in your hands.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-run your evals after switching&lt;/strong&gt;, and treat any pinned-baseline eval as invalidated as of May 15. Capture a fresh baseline against an explicit model+effort you control.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reconcile one xAI invoice line by line&lt;/strong&gt; against your internal cost dashboard. If they don't match, your attribution is keying off the sent slug and needs to key off actual billed usage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a cost-per-token alert&lt;/strong&gt;, not just a request-count alert. This entire class of failure is invisible to availability monitoring and visible only to spend monitoring.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason this one is worth a sprint and not a backlog ticket: every other model retirement this year threw an error eventually. This one is engineered specifically &lt;em&gt;not&lt;/em&gt; to. "Your code keeps working" is the failure mode, not the mitigation.&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 third-party APIs and SDKs for breaking changes like this one — including model retirements, silent slug redirects, and pricing-tier remaps — and surfaces them before the invoice does. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>xai</category>
      <category>grok</category>
      <category>llm</category>
      <category>deprecation</category>
    </item>
    <item>
      <title>Gemini's Interactions API default flips May 26 — your interaction.outputs reads will go undefined and tool calls silently stop</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 18 May 2026 00:45:54 +0000</pubDate>
      <link>https://dev.to/flarecanary/geminis-interactions-api-default-flips-may-26-your-interactionoutputs-reads-will-go-undefined-nb0</link>
      <guid>https://dev.to/flarecanary/geminis-interactions-api-default-flips-may-26-your-interactionoutputs-reads-will-go-undefined-nb0</guid>
      <description>&lt;p&gt;If your code calls Google's Gemini Interactions API (&lt;code&gt;/v1beta/interactions&lt;/code&gt;), there is a short, silent window opening on &lt;strong&gt;May 26, 2026&lt;/strong&gt;. On that date, Google flips the default response schema. &lt;code&gt;interaction.outputs&lt;/code&gt; becomes &lt;code&gt;interaction.steps&lt;/code&gt;, &lt;code&gt;response_mime_type&lt;/code&gt; folds into a polymorphic &lt;code&gt;response_format&lt;/code&gt;, &lt;code&gt;image_config&lt;/code&gt; moves out of &lt;code&gt;generation_config&lt;/code&gt;, and the streaming event names you wired listeners to all rename. The legacy schema still works &lt;em&gt;if&lt;/em&gt; you explicitly send &lt;code&gt;Api-Revision: 2026-05-07&lt;/code&gt; — until &lt;strong&gt;June 8, 2026&lt;/strong&gt;, when it's removed for good.&lt;/p&gt;

&lt;p&gt;Most of these surfaces don't fail loudly. They fail by returning &lt;code&gt;undefined&lt;/code&gt;, by ignoring a config field, or by emitting an SSE event your handler doesn't recognize. The first signal is usually a downstream consumer noticing that the model's reply is empty, or that the tool dispatch loop stopped firing, or that the generated image came back in the wrong aspect ratio.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;May 7, 2026&lt;/strong&gt; — opt-in begins. New SDKs (Python ≥2.0.0, JS ≥2.0.0) ship; REST clients can opt in with &lt;code&gt;Api-Revision: 2026-05-20&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May 26, 2026&lt;/strong&gt; — &lt;strong&gt;default flips&lt;/strong&gt;. Any REST call without an &lt;code&gt;Api-Revision&lt;/code&gt; header gets the new schema. SDKs older than 2.0.0 keep getting the legacy shape (for now).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;June 8, 2026&lt;/strong&gt; — legacy schema removed permanently. The &lt;code&gt;Api-Revision: 2026-05-07&lt;/code&gt; opt-out stops working. Older SDKs that depend on &lt;code&gt;outputs&lt;/code&gt; start breaking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dangerous window is May 26 → June 8: anything pinned to the legacy &lt;em&gt;header&lt;/em&gt; keeps working, but anything calling REST without a header — most ad-hoc integrations and a lot of internal tooling — gets the new shape silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;outputs[]&lt;/code&gt; → &lt;code&gt;steps[]&lt;/code&gt; (the read path you almost certainly have)
&lt;/h3&gt;

&lt;p&gt;Legacy response:&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;"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;"int_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&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="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;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Why did the chicken cross the road?"&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;New response:&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;"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;"int_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&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="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;"model_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&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="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;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Why did the chicken cross the road?"&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;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 shape change cascades:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;outputs&lt;/code&gt; is gone. &lt;code&gt;interaction.outputs&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; (JS) or raises &lt;code&gt;KeyError&lt;/code&gt; (Python dict) or fails attribute access (typed clients).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;text&lt;/code&gt; field moves one level deeper, behind &lt;code&gt;content[0]&lt;/code&gt;. In Python: &lt;code&gt;interaction.outputs[-1].text&lt;/code&gt; → &lt;code&gt;interaction.steps[-1].content[0].text&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A new top-level step type, &lt;code&gt;user_input&lt;/code&gt;, appears in &lt;code&gt;GET&lt;/code&gt; responses (full timeline). Code that iterates &lt;code&gt;outputs&lt;/code&gt; assuming every entry is model-emitted will now also see the user's prompt as a step — fine if you filter, broken if you concatenate everything you see.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;response_mime_type&lt;/code&gt; → polymorphic &lt;code&gt;response_format&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Legacy request for JSON output:&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;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this article."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_mime_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;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&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;"object"&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;"summary"&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;"string"&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;New request:&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;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this article."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mime_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;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"schema"&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;"object"&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;"summary"&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;"string"&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;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;&lt;code&gt;response_mime_type&lt;/code&gt; at the top level is gone — it folds into &lt;code&gt;response_format.mime_type&lt;/code&gt;. The schema moves into &lt;code&gt;response_format.schema&lt;/code&gt;. The discriminator that picks text vs image vs audio is &lt;code&gt;response_format.type&lt;/code&gt;. After May 26, the server silently ignores the legacy top-level &lt;code&gt;response_mime_type&lt;/code&gt; field; your JSON-mode call quietly stops being JSON-mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;image_config&lt;/code&gt; moves out of &lt;code&gt;generation_config&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Legacy:&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;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate an image of a sunset over the ocean."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"generation_config"&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;"image_config"&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;"aspect_ratio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1:1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"image_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1K"&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;New:&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;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate an image of a sunset over the ocean."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&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;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mime_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;"image/jpeg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aspect_ratio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1:1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1K"&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 fields are gone from &lt;code&gt;generation_config&lt;/code&gt;. Server-side they're not read from that location anymore. The image still generates — just at whatever the new default aspect ratio and size are. Your "always render 1:1 thumbnails" job starts shipping 16:9 widescreens with no log line to explain it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Streaming SSE event names rename
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Legacy&lt;/th&gt;
&lt;th&gt;New&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interaction.start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;interaction.created&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content.start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;step.start&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content.delta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;step.delta&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content.stop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;step.stop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interaction.complete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;interaction.completed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interaction.status_update&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;interaction.in_progress&lt;/code&gt;, &lt;code&gt;interaction.requires_action&lt;/code&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you wired event listeners with a switch on &lt;code&gt;event&lt;/code&gt; name or with EventSource handlers like &lt;code&gt;es.addEventListener('content.delta', …)&lt;/code&gt;, after May 26 those events never fire. The stream still arrives — &lt;code&gt;step.delta&lt;/code&gt; frames pour in — but your callback isn't subscribed to them. The user-facing symptom is "the response just hangs."&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Function calls live in &lt;code&gt;steps&lt;/code&gt;, not &lt;code&gt;outputs&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Legacy tool-call response:&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;"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;"int_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"requires_action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&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="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;"function_call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"fc_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Boston, MA"&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;New:&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;"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;"int_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"requires_action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&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="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;"function_call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"fc_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Boston, MA"&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;Tool dispatch loops typically iterate &lt;code&gt;outputs&lt;/code&gt;, find &lt;code&gt;type === "function_call"&lt;/code&gt; entries, invoke the handler, then submit results back. After the flip, &lt;code&gt;outputs&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, so the loop iterates nothing, finds no function calls, returns the unaltered &lt;code&gt;requires_action&lt;/code&gt; interaction to the caller, and the agent stalls. No exception — just an agent that "didn't decide to call a tool this turn." The same trap applies to &lt;code&gt;google_search_call&lt;/code&gt;, &lt;code&gt;google_search_result&lt;/code&gt;, and &lt;code&gt;thought&lt;/code&gt; step types, all of which now live under &lt;code&gt;steps&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The &lt;code&gt;thought&lt;/code&gt; step shape changes too
&lt;/h3&gt;

&lt;p&gt;Legacy &lt;code&gt;thought&lt;/code&gt; was minimal:&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;"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;"thought"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123..."&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;New &lt;code&gt;thought&lt;/code&gt; carries a structured summary:&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;"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;"thought"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&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;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I need to check the weather in Boston..."&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;"signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123..."&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;Any logging or audit code reading &lt;code&gt;thought.text&lt;/code&gt; (which never existed but was a common guess) silently gets &lt;code&gt;undefined&lt;/code&gt;. Code that round-trips &lt;code&gt;thought&lt;/code&gt; back to Gemini for stateless continuation now has to preserve the &lt;code&gt;summary&lt;/code&gt; array — drop it and the model loses its scratchpad.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Surfaces
&lt;/h2&gt;

&lt;p&gt;Walking through the six places this fails quietly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Response readers&lt;/strong&gt; — &lt;code&gt;interaction.outputs[-1].text&lt;/code&gt; → &lt;code&gt;undefined&lt;/code&gt; (or &lt;code&gt;KeyError&lt;/code&gt;/&lt;code&gt;AttributeError&lt;/code&gt;). Templated chat UIs render the empty string. Logs show "model returned no text" but the model did return text; you just stopped reading it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-mode validators&lt;/strong&gt; — Top-level &lt;code&gt;response_mime_type: "application/json"&lt;/code&gt; is silently ignored after May 26. The model still tries to follow the schema if you also send a &lt;code&gt;response_format&lt;/code&gt;, but enforcement weakens. Free-form responses slip past &lt;code&gt;JSON.parse&lt;/code&gt; on the client and crash downstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image params&lt;/strong&gt; — &lt;code&gt;generation_config.image_config&lt;/code&gt; is silently dropped. Aspect ratio and size revert to defaults. Thumbnails come back at the wrong dimensions; layout breaks; humans complain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming listeners&lt;/strong&gt; — &lt;code&gt;addEventListener('content.delta', …)&lt;/code&gt; never fires. The stream finishes; your token buffer stays empty; the UI shows the spinner forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool dispatchers&lt;/strong&gt; — function-call loops keyed on &lt;code&gt;outputs[].type === 'function_call'&lt;/code&gt; find no calls. The agent silently no-ops on a turn the model intended to use tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless history round-trips&lt;/strong&gt; — passing the prior &lt;code&gt;outputs&lt;/code&gt; array as input to the next request after May 26 → the server doesn't recognize it as a valid input shape (or worse, partially interprets it). Conversation history detaches; the model loses context with no error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How To Detect It Before May 26
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Opt in now and run your test suite.&lt;/strong&gt; This is the cheapest signal. Add the header to your dev/staging environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://generativelanguage.googleapis.com/v1beta/interactions?key=&lt;/span&gt;&lt;span class="nv"&gt;$GEMINI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Api-Revision: 2026-05-20"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ ... }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or upgrade to Python &lt;code&gt;≥2.0.0&lt;/code&gt; / JS &lt;code&gt;≥2.0.0&lt;/code&gt;. Anything that broke in dev under the new schema will break in prod on May 26. The header buys you a controlled fire drill in staging before the default change forces it on you in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grep for the legacy field paths.&lt;/strong&gt; All of these are exposed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.outputs&lt;/code&gt; (esp. &lt;code&gt;.outputs[&lt;/code&gt;, &lt;code&gt;.outputs[-1]&lt;/code&gt;, &lt;code&gt;outputs.length&lt;/code&gt;, &lt;code&gt;outputs.map&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;response_mime_type&lt;/code&gt; (anywhere in request construction)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image_config&lt;/code&gt; (inside &lt;code&gt;generation_config&lt;/code&gt; blocks)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;'content.delta'&lt;/code&gt;, &lt;code&gt;'content.start'&lt;/code&gt;, &lt;code&gt;'content.stop'&lt;/code&gt;, &lt;code&gt;'interaction.complete'&lt;/code&gt;, &lt;code&gt;'interaction.start'&lt;/code&gt; (SSE event handlers)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type === 'function_call'&lt;/code&gt; inside loops over &lt;code&gt;outputs&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each match is a migration site. The streaming event names are the easiest to miss — a single &lt;code&gt;addEventListener&lt;/code&gt; call buried in a chat UI can take down the whole streaming path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add a schema check on the response.&lt;/strong&gt; Until you've migrated, log a warning if &lt;code&gt;response.outputs&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; and &lt;code&gt;response.steps&lt;/code&gt; is present. After June 8 the legacy header stops working, so this assertion becomes a permanent canary for "are we accidentally hitting an unmigrated client path."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Pin &lt;code&gt;Api-Revision: 2026-05-07&lt;/code&gt; only as a temporary safety net.&lt;/strong&gt; It buys you until June 8 — about two weeks past the default flip. Use it to keep prod alive while you migrate; don't use it as the long-term answer. The header stops being honored on June 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Worth Paying Attention To
&lt;/h2&gt;

&lt;p&gt;The same code path that ran for a year against &lt;code&gt;outputs[-1].text&lt;/code&gt; keeps compiling, keeps passing type checks (if your types come from an older SDK), keeps returning a &lt;code&gt;200&lt;/code&gt;. The model itself is unchanged. The bytes on the wire are different bytes in different places. None of the usual signals — HTTP status, exception, SDK error — fire.&lt;/p&gt;

&lt;p&gt;The pattern across all six silent surfaces is the same: a vendor moves a value to a new path, and old code reading the old path gets back a value (&lt;code&gt;undefined&lt;/code&gt;, the default, the empty array) that's a &lt;em&gt;valid&lt;/em&gt; answer to a different question. The wire is silent because the language is silent: &lt;code&gt;undefined&lt;/code&gt; is a real JS value; an empty array is a real iteration; the default aspect ratio is a real image.&lt;/p&gt;

&lt;p&gt;If you run anything on Gemini's Interactions API, the cheapest move you can make right now is to add the &lt;code&gt;Api-Revision: 2026-05-20&lt;/code&gt; header in staging and let the tests find what's exposed. However many days are left before May 26, spending them on a controlled staging drill beats discovering this from a confused user report after the default flips.&lt;/p&gt;

</description>
      <category>google</category>
      <category>ai</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Notion's API Now Caps Pagination at 10,000 Results — Your 'Fetch All Rows' Sync Is Silently Truncating</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 13 May 2026 04:04:04 +0000</pubDate>
      <link>https://dev.to/flarecanary/notions-api-now-caps-pagination-at-10000-results-your-fetch-all-rows-sync-is-silently-4j99</link>
      <guid>https://dev.to/flarecanary/notions-api-now-caps-pagination-at-10000-results-your-fetch-all-rows-sync-is-silently-4j99</guid>
      <description>&lt;p&gt;If you have a Notion integration that "fetches all the rows in this database" — a sync job, an export, a reporting pipeline — it may have started returning incomplete data without throwing anything. As of an early-2026 API change, Notion's paginated query and list endpoints enforce a hard &lt;strong&gt;10,000-result maximum pagination depth&lt;/strong&gt;. Past that point you don't get an error. You get a &lt;code&gt;200 OK&lt;/code&gt;, no &lt;code&gt;next_cursor&lt;/code&gt;, and a new field telling you the result set was truncated — a field most existing code has never heard of and doesn't check.&lt;/p&gt;

&lt;p&gt;So the loop terminates normally, the caller treats the partial set as the whole set, and everything downstream — the warehouse table, the dashboard, the "we synced N records" log line — is quietly wrong for every database with more than 10k matching rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;The classic Notion pagination contract was: call the endpoint, read &lt;code&gt;results&lt;/code&gt;, if &lt;code&gt;has_more&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; call again with &lt;code&gt;start_cursor: next_cursor&lt;/code&gt;, repeat until &lt;code&gt;has_more&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;. That contract still holds — for the first 10,000 results.&lt;/p&gt;

&lt;p&gt;Once a paginated query would cross the 10,000-result boundary, Notion stops the cursor walk and returns a response shaped 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;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&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="err"&gt;...the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;within&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="err"&gt;k&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;window...&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;"next_cursor"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_status"&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;"incomplete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"incomplete_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"query_result_limit_reached"&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 tell is &lt;code&gt;request_status&lt;/code&gt;. On a normal, fully-paginated response it's either absent or &lt;code&gt;"type": "complete"&lt;/code&gt;. On a truncated one it's &lt;code&gt;"type": "incomplete"&lt;/code&gt; with &lt;code&gt;incomplete_reason: "query_result_limit_reached"&lt;/code&gt;. Notice what &lt;em&gt;isn't&lt;/em&gt; different: &lt;code&gt;has_more&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; (just like a real end-of-results), &lt;code&gt;next_cursor&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; (just like a real end-of-results), the HTTP status is &lt;code&gt;200&lt;/code&gt;, and the &lt;code&gt;results&lt;/code&gt; array is a perfectly valid array of perfectly valid pages. Nothing about the response trips an exception, a schema validator, or an HTTP-status check.&lt;/p&gt;

&lt;p&gt;This applies to the paginated endpoints that can match large numbers of objects — database/data-source queries, and the list endpoints (users, comments, block children, search) — anywhere a single logical query could exceed 10k results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Silent Failure, Not a Loud One
&lt;/h2&gt;

&lt;p&gt;Walk through what each layer of a typical integration sees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pagination loop&lt;/strong&gt;: &lt;code&gt;while (response.has_more) { ... }&lt;/code&gt;. On a truncated response &lt;code&gt;has_more&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, so the loop exits cleanly on the first iteration that hits the cap. From the loop's perspective this is indistinguishable from "we reached the last page." No retry, no warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SDK&lt;/strong&gt;: the official &lt;code&gt;@notionhq/client&lt;/code&gt; (and the auto-paginating helpers built on it, like &lt;code&gt;iteratePaginatedAPI&lt;/code&gt;) follow the same &lt;code&gt;has_more&lt;/code&gt;/&lt;code&gt;next_cursor&lt;/code&gt; contract. They stop when the cursor runs out. They don't inspect &lt;code&gt;request_status&lt;/code&gt; and they don't throw — there's nothing to throw on; the server returned a valid &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validation&lt;/strong&gt;: if you validate the response, &lt;code&gt;request_status&lt;/code&gt; is an &lt;em&gt;additive&lt;/em&gt; field. A truncated response is still a structurally valid list response. Strict validators that reject &lt;em&gt;unknown&lt;/em&gt; fields might trip — but most don't, and even then the error says "unexpected field," not "your data is incomplete."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your "rows synced" metric&lt;/strong&gt;: it logs however many rows came back. 10,000 is a plausible-looking number. Nobody alerts on "synced exactly 10,000 records" because that's not obviously wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data consumer&lt;/strong&gt;: the warehouse table, the BI dashboard, the downstream API. It sees a smaller-than-expected dataset and has no way to know whether that's because rows were deleted in Notion or because the sync truncated. It renders. It looks fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first real signal is usually a human: someone notices a record that exists in Notion isn't in the report, files a "data is stale" ticket, and a few hours of debugging later you find the sync has been silently capped for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's Exposed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database-to-warehouse / database-to-spreadsheet sync tools&lt;/strong&gt; pulling large Notion databases (project trackers, CRMs, content calendars, issue logs that have grown over years).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup and export jobs&lt;/strong&gt; that walk every row of every database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal dashboards and reporting pipelines&lt;/strong&gt; that re-query a big database on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration scripts&lt;/strong&gt; moving content out of Notion — the worst case, because you run it once, it "succeeds," you decommission the source, and you don't discover the missing 30% until much later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything using &lt;code&gt;iteratePaginatedAPI&lt;/code&gt; or a hand-rolled &lt;code&gt;has_more&lt;/code&gt; loop&lt;/strong&gt; against a query that returns more than 10k objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your databases are all comfortably under 10k matching rows for every query you run, you're fine — for now. The risk is the database that crosses the line six months from now, on a code path nobody's looked at since it was written.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To Detect It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Check &lt;code&gt;request_status&lt;/code&gt; on every paginated response.&lt;/strong&gt; This is the actual fix. Anywhere you loop on &lt;code&gt;has_more&lt;/code&gt;, also look at &lt;code&gt;request_status&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFullPage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&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;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queryAllRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filter&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&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;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="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data_source_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dataSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;start_cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;rows&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;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// The new part: detect truncation explicitly.&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;request_status&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;incomplete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`Notion query truncated: &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;request_status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;incomplete_reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Got &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rows; result set exceeds the 10,000 pagination cap. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Narrow the query with a more selective filter or partition by a property range.`&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_more&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="nx"&gt;cursor&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;next_cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&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;Throwing is the right default for a sync job: a loud failure you can see beats a quiet truncation you can't. If you'd rather degrade gracefully, at minimum increment a metric and log a warning with the row count — don't let &lt;code&gt;incomplete&lt;/code&gt; pass unobserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Re-architect queries that legitimately exceed 10k.&lt;/strong&gt; The cap is per &lt;em&gt;query&lt;/em&gt;, not per database. If a database genuinely has more than 10,000 rows you care about, partition the query: filter by a date range, a status, a created-time window, or an alphabetical slice of a title property, and walk each partition separately. Each partition's pagination still has to stay under 10k, so size your partitions accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add a cross-check on row counts.&lt;/strong&gt; If you know roughly how many rows a database should have (or you can get a count another way), assert that your sync pulled within tolerance of it. A sync that returns exactly 10,000 rows when you expected ~14,000 should page someone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Search your codebase for the patterns that are exposed.&lt;/strong&gt; Grep for &lt;code&gt;has_more&lt;/code&gt;, &lt;code&gt;next_cursor&lt;/code&gt;, &lt;code&gt;iteratePaginatedAPI&lt;/code&gt;, &lt;code&gt;start_cursor&lt;/code&gt;. Every match against a Notion query is a place to add the &lt;code&gt;request_status&lt;/code&gt; check. If you find the string &lt;code&gt;query_result_limit_reached&lt;/code&gt; showing up in logs you didn't write that handler for, it's already happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;This is the same shape as a lot of recent API changes: a vendor adds a limit, communicates it as a new &lt;em&gt;field&lt;/em&gt; rather than a new &lt;em&gt;error&lt;/em&gt;, and the failure mode lands in the gap between "the response is structurally valid" and "the data is actually complete." HTTP-status checks miss it. Schema validators miss it. SDKs that only know the old &lt;code&gt;has_more&lt;/code&gt; contract miss it. The only thing that catches it is code — or monitoring — that knows the new field exists and treats &lt;code&gt;incomplete&lt;/code&gt; as the alarm it is.&lt;/p&gt;

&lt;p&gt;If you run integrations against third-party APIs, this is worth a standing habit: when a provider adds a status/result-metadata field to a response you already parse, assume there's a silent-failure path hiding behind it, and go check what your code does when that field says "incomplete."&lt;/p&gt;

</description>
      <category>notion</category>
      <category>api</category>
      <category>monitoring</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Supabase's Management API OAuth Endpoint Switches From 201 to 200 on May 26 — Here's What Silently Breaks</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 11 May 2026 04:08:07 +0000</pubDate>
      <link>https://dev.to/flarecanary/supabases-management-api-oauth-endpoint-switches-from-201-to-200-on-may-26-heres-what-silently-4cee</link>
      <guid>https://dev.to/flarecanary/supabases-management-api-oauth-endpoint-switches-from-201-to-200-on-may-26-heres-what-silently-4cee</guid>
      <description>&lt;p&gt;On May 26, 2026, the OAuth token exchange endpoint for Supabase's Management API — &lt;code&gt;https://api.supabase.com/v1/oauth/token&lt;/code&gt; — will stop returning &lt;strong&gt;&lt;code&gt;201 Created&lt;/code&gt;&lt;/strong&gt; on success and start returning &lt;strong&gt;&lt;code&gt;200 OK&lt;/code&gt;&lt;/strong&gt;. Same body, same fields, same access tokens. Just a different number on the status line.&lt;/p&gt;

&lt;p&gt;Supabase's &lt;a href="https://supabase.com/changelog/45468-breaking-change-oauth-token-endpoint-will-return-http-200-instead-of-201" rel="noopener noreferrer"&gt;announcement&lt;/a&gt; is short and accurate: most clients won't notice, because they check for a 2XX success range. The libraries it calls out by name (axios, the Fetch API, MCP TypeScript SDK, Vercel AI SDK) all do that. So if you're using &lt;code&gt;supabase-management-js&lt;/code&gt; or any other 2XX-range-aware HTTP client, you're done — go read something else.&lt;/p&gt;

&lt;p&gt;The teams that &lt;em&gt;will&lt;/em&gt; notice are the ones that wrote their own token-exchange handler and put &lt;code&gt;if (response.status === 201)&lt;/code&gt; somewhere on the success path. Or &lt;code&gt;assert resp.status_code == 201&lt;/code&gt; in a test. Or a log filter that only counts &lt;code&gt;201&lt;/code&gt; as a successful token exchange. Those clients will silently misroute a successful response to the error branch starting May 26.&lt;/p&gt;

&lt;p&gt;This article is about why that misrouting is quieter than it looks, and where the second-order damage lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changes
&lt;/h2&gt;

&lt;p&gt;The endpoint is &lt;code&gt;POST https://api.supabase.com/v1/oauth/token&lt;/code&gt;, used by third-party Supabase integrations to exchange an authorization code for an access token, and to refresh those tokens later. It's a standard OAuth 2.1 token endpoint — form-encoded request, JSON response with &lt;code&gt;access_token&lt;/code&gt;, &lt;code&gt;refresh_token&lt;/code&gt;, &lt;code&gt;token_type&lt;/code&gt;, &lt;code&gt;expires_in&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Today:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;201&lt;/span&gt; &lt;span class="ne"&gt;Created&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_refresh_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rest projects.read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_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;"bearer"&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;After May 26:&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_refresh_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rest projects.read"&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 body doesn't move. No headers change. The rationale Supabase gives is direct: &lt;a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1" rel="noopener noreferrer"&gt;OAuth 2.1 Section 3.2.3&lt;/a&gt; mandates &lt;code&gt;200&lt;/code&gt; from token endpoints, and "Returning &lt;code&gt;201&lt;/code&gt; is non-compliant and has caused token exchange failures with some strict OAuth clients."&lt;/p&gt;

&lt;p&gt;In other words, the fix has been &lt;em&gt;making&lt;/em&gt; something silently fail for strict-spec clients. The migration moves the silent failure to the lax-spec clients on May 26. There is no version of this where nobody breaks; Supabase is choosing the side of the spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Quiet Surfaces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Surface 1: Strict-equality status checks misroute success into the error branch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The classic shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.supabase.com/v1/oauth/token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{...});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&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="mi"&gt;201&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;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="c1"&gt;// Otherwise: log and return an error&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OAuth token exchange failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On May 26, this code starts logging &lt;code&gt;"OAuth token exchange failed 200"&lt;/code&gt; and returning &lt;code&gt;{ ok: false }&lt;/code&gt; — &lt;em&gt;after Supabase has already minted a valid access token and rotated the authorization code&lt;/em&gt;. The auth code is single-use. The success branch never ran. The tokens never got saved. The user sees "we couldn't connect your Supabase account," tries again, and on the retry, the original auth code has already been consumed — they get a fresh OAuth flow but the integration looks flaky.&lt;/p&gt;

&lt;p&gt;The failure mode is the worst of both worlds: it costs you a successful authorization (the code is burned) &lt;em&gt;and&lt;/em&gt; it presents to the user as a generic connection failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 2: Test assertions silently flip green-to-red — but only on CI, not locally.&lt;/strong&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_oauth_token_exchange&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;oauth_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchange_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test_auth_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&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;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;-- the bomb
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-May 26, this test passes against a recorded fixture, against staging, against your local mock server. It also passes against production. After May 26, it fails against production &lt;em&gt;only&lt;/em&gt;. The integration tests in CI start failing on the &lt;code&gt;201&lt;/code&gt; assertion line — but only the ones that hit real Supabase. Mocked tests, snapshot tests, and stubbed tests all keep passing because the fixtures still encode the old behavior.&lt;/p&gt;

&lt;p&gt;This is the silent-in-CI shape that hits hardest: the test suite is &lt;em&gt;louder&lt;/em&gt; than the production code (CI starts failing) while the production code is &lt;em&gt;quieter&lt;/em&gt; than it should be (running customers hit token errors and you have to triangulate why). Teams that run only mocked tests on PRs (and only run real integrations nightly) might not notice until the nightly fires.&lt;/p&gt;

&lt;p&gt;The fix is the same as the production fix — change &lt;code&gt;== 201&lt;/code&gt; to &lt;code&gt;&amp;lt; 300&lt;/code&gt; or &lt;code&gt;in range(200, 300)&lt;/code&gt; — but it needs to happen in the test fixtures and the snapshot files too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 3: Authorization-code reuse on retry burns the flow.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the second-order one and it's subtle. OAuth 2.1 authorization codes are single-use; the spec is strict. If a strict-equality check misroutes the &lt;code&gt;200&lt;/code&gt; success into a retry path that re-POSTs the same &lt;code&gt;code&lt;/code&gt; to &lt;code&gt;/v1/oauth/token&lt;/code&gt;, the second request will fail (the code was already consumed). The integration logs the second failure, surfaces a generic error to the user, and may emit a stack trace pointing at the &lt;em&gt;retry&lt;/em&gt; call site — making the root cause look like "Supabase rejected our authorization code" instead of "our success handler is checking for the wrong status code."&lt;/p&gt;

&lt;p&gt;If your client has automatic retry-on-non-2XX-but-treat-201-as-success logic anywhere (Polly, Tenacity, &lt;code&gt;axios-retry&lt;/code&gt; with a custom predicate, a hand-rolled retryer that treats &lt;code&gt;200&lt;/code&gt; as an unexpected status), this is the shape you'll see: the first exchange succeeded; the retry exhausted the code; the user sees "OAuth flow failed."&lt;/p&gt;

&lt;p&gt;The Supabase API will respond to the doomed retry with a 400 and &lt;code&gt;{ "error": "invalid_grant", "error_description": "Authorization code has been used" }&lt;/code&gt; — searching that string from a confused engineer's perspective is the path back. (If you only find this article after May 26, &lt;em&gt;that&lt;/em&gt; error string is the canonical post-mortem hook.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 4: Observability and metrics start undercounting successful exchanges.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three patterns to grep for in your monitoring code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log filters keyed on &lt;code&gt;201&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;where status_code = 201&lt;/code&gt; in your Splunk/Datadog query for "successful Supabase auth" silently drops to zero on May 26. The dashboard reads as "outage," but nothing broke — the queries did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook/audit logging that records only specific status codes.&lt;/strong&gt; Some custom audit pipelines emit different events for &lt;code&gt;201 Created&lt;/code&gt; vs other 2XX. After May 26, the &lt;code&gt;created&lt;/code&gt; event stream stops; the audit log shows nothing where there should be daily entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerting rules that fire on "no 201 in the last hour."&lt;/strong&gt; Pages on-call when the underlying system is healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cure here is the same shape as the test fix: replace status-equality with status-range, regenerate dashboards, update audit-event mappings. The work is small if you grep for it; the work is bottomless if you wait for the dashboards to tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Audit Right Now
&lt;/h2&gt;

&lt;p&gt;Three groups, ranked by likely impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Anyone building a Supabase integration from scratch.&lt;/strong&gt; If you wrote your own OAuth client (because you needed something &lt;code&gt;supabase-management-js&lt;/code&gt; didn't expose, or you're in a language without a maintained Supabase library, or you wanted to control the token-cache layer yourself), search your codebase for the literal &lt;code&gt;201&lt;/code&gt; near anything OAuth-related.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Anyone shipping a Supabase integration in a typed language with overly-specific status types.&lt;/strong&gt; The pattern looks like &lt;code&gt;enum Response { Created(Tokens), ... }&lt;/code&gt; or &lt;code&gt;case .created(let body): ...&lt;/code&gt; — Swift, Rust, Kotlin, Scala. Strict status-typing is what bites this surface hardest; the compiler can't help you, but a grep for the literal &lt;code&gt;201&lt;/code&gt; or the language-specific &lt;code&gt;created&lt;/code&gt; enum variant will.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Anyone whose CI pipeline does real-network integration tests against &lt;code&gt;api.supabase.com&lt;/code&gt;.&lt;/strong&gt; Even if the production code is fine, the test fixtures might pin &lt;code&gt;201&lt;/code&gt; and turn the build red on May 26 for no production-impacting reason.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Migration Is Trivial; The Audit Is the Work
&lt;/h2&gt;

&lt;p&gt;The actual code change is a one-liner per call site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- if (resp.status === 201) {
&lt;/span&gt;&lt;span class="gi"&gt;+ if (resp.ok) {  // covers 200, 201, and anything else in 2XX
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- if resp.status_code == 201:
&lt;/span&gt;&lt;span class="gi"&gt;+ if 200 &amp;lt;= resp.status_code &amp;lt; 300:
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the more spec-precise version that matches OAuth 2.1's expectations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- if resp.status_code == 201:
&lt;/span&gt;&lt;span class="gi"&gt;+ if resp.status_code == 200:
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first form is the most forgiving and the one Supabase's announcement recommends. The third is the most spec-faithful and breaks again only if Supabase migrates to a different success code in the future (which they won't — &lt;code&gt;200&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the spec).&lt;/p&gt;

&lt;p&gt;Once the production code is fixed, sweep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unit tests asserting on response status&lt;/li&gt;
&lt;li&gt;Snapshot tests / contract tests with hardcoded &lt;code&gt;201&lt;/code&gt; literals&lt;/li&gt;
&lt;li&gt;Logging templates with &lt;code&gt;"status=201"&lt;/code&gt; formatted in&lt;/li&gt;
&lt;li&gt;Monitoring queries grouped or filtered by status code&lt;/li&gt;
&lt;li&gt;Audit-log producers emitting different event types per 2XX status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've got grep, this is a 20-minute job. If you don't, it's the kind of thing that surfaces in a frantic Slack message four days after May 26.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern, Beyond Supabase
&lt;/h2&gt;

&lt;p&gt;Status-code compliance migrations are a recurring shape: an API was returning the wrong-but-working status, strict spec compliance forces a change, the change is technically harmless, and the only code that breaks is the code that violated the contract twice — once by depending on a specific status code, and once by treating the wrong status code as canonical. The same kind of move shows up periodically in &lt;a href="https://docs.stripe.com/upgrades" rel="noopener noreferrer"&gt;Stripe API version changes&lt;/a&gt;, in &lt;a href="https://docs.github.com/en/rest/about-the-rest-api/breaking-changes" rel="noopener noreferrer"&gt;GitHub's REST API breaking changes&lt;/a&gt;, in payment provider response normalizations.&lt;/p&gt;

&lt;p&gt;What's interesting from a runtime-monitoring angle is that the affected systems are &lt;em&gt;exactly the ones with the least observability&lt;/em&gt; — they're the hand-rolled clients, the custom integrations, the test fixtures that nobody touches. The libraries with strong status-code abstractions absorb the change silently, which is the right behavior; the systems without those abstractions absorb it as silent failure.&lt;/p&gt;

&lt;p&gt;This is the same pattern we see across other "minor" API changes that turn into customer-impacting bugs weeks later: the migration is two lines of code, but the audit is across every place anyone touched the API surface — and most teams don't have a single inventory of those places. That's the lookup problem that &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; exists to solve at the response-shape layer: watch the API, catch the structural change, route the alert to whoever's name is on the integration. The body isn't changing on May 26, so a content monitor wouldn't catch this one — but a status-code or header diff would.&lt;/p&gt;

&lt;p&gt;If you're shipping a Supabase Management API integration and &lt;code&gt;201&lt;/code&gt; is anywhere in your codebase, this is the moment to find it. Two weeks of runway, a one-line fix, and a quiet failure mode if you wait.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're maintaining a Supabase OAuth integration and find this article while staring at an &lt;code&gt;invalid_grant&lt;/code&gt; error you don't remember writing, the fix is at the success-handler call site, not at the retry layer. Drop a comment if the failure shape looked different from what I described — the more shapes we catalog the better.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>oauth</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>AEM Cloud Stops Receiving Adobe Updates June 11 If You Use Deprecated APIs — Here's the List and How to Tell</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 11 May 2026 04:02:00 +0000</pubDate>
      <link>https://dev.to/flarecanary/aem-cloud-stops-receiving-adobe-updates-june-11-if-you-use-deprecated-apis-heres-the-list-and-4c12</link>
      <guid>https://dev.to/flarecanary/aem-cloud-stops-receiving-adobe-updates-june-11-if-you-use-deprecated-apis-heres-the-list-and-4c12</guid>
      <description>&lt;p&gt;On June 11, 2026, AEM Cloud Service environments still running deprecated Java APIs stop receiving Adobe release updates. They'll keep serving traffic. They'll keep handling authoring. They just go silently un-patched — no security fixes, no bug fixes, no platform updates — because Adobe will hold those updates back from environments that haven't successfully completed a pipeline run on the new API surface.&lt;/p&gt;

&lt;p&gt;That's the part that matters and that's the part most teams will miss. Not because Adobe didn't tell anyone, but because the way Adobe rolled this out has at least four surfaces where a team can quietly walk past the warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Escalation, In Adobe's Own Words
&lt;/h2&gt;

&lt;p&gt;Pulled from Adobe's &lt;a href="https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/release-notes/deprecated-removed-features" rel="noopener noreferrer"&gt;Deprecated and Removed Features&lt;/a&gt; page:&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;What Adobe does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jan 26, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Actions Center notification emails are sent as a reminder to remove usage of these APIs, &lt;strong&gt;if a pipeline has been recently executed&lt;/strong&gt;."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Feb 26, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud Manager pipelines using deprecated APIs &lt;strong&gt;pause&lt;/strong&gt; during the Code Quality step. A Deployment Manager, Project Manager, or Business Owner can override and proceed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apr 14, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud Manager pipelines &lt;strong&gt;fail&lt;/strong&gt; during the Code Quality step. Deployments blocked until the deprecated API usage is removed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;May 4, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cutoff: "If the updates are not made by May 4th, you will no longer receive AEM version updates" — until a successful fullstack pipeline run lands.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jun 11, 2026&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Environments still using deprecated APIs will not receive critical Adobe release updates and are not subject to Adobe's standard commitments around performance and availability."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you read that progression closely, the failure mode at each step is different — and that's where teams lose track.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Quiet Surfaces
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Surface 1: The notification email assumes you've been deploying.&lt;/strong&gt; The Jan 26 email goes to environments where a pipeline "has been recently executed." A long-tail production environment that's been stable for six months — the one running an internal portal, a partner microsite, a staging environment kept around for a single QA flow — never gets the email. The team that owns it doesn't know anything is wrong until June.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 2: The February pause is overridable.&lt;/strong&gt; Code Quality flags deprecated API usage. The pipeline pauses. A Deployment Manager / Project Manager / Business Owner with the right role can click "override and proceed." Under release pressure (a critical fix, a marketing deadline, an end-of-quarter launch), that override is going to get clicked. The override doesn't fix anything — it just lets the deploy through, and the deprecated APIs stay in the environment. The pipeline emails the override-approver, but the actual &lt;em&gt;engineer&lt;/em&gt; who needs to migrate the code might never see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 3: April failures are deploy-time, not runtime.&lt;/strong&gt; When April 14 hits and pipelines start failing the Code Quality step, your &lt;em&gt;running&lt;/em&gt; AEM environment doesn't change. It keeps serving requests. Authors keep authoring. The breakage is in your ability to deploy &lt;em&gt;new&lt;/em&gt; code. If your team isn't shipping much (a maintenance phase, a feature freeze, a project paused for re-org), the pipeline failures don't fire until someone tries to ship — which might be weeks after the deadline. By then May 4 has passed and your environment is no longer receiving Adobe updates either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 4: June 11 is the &lt;em&gt;quietest&lt;/em&gt; failure of the four.&lt;/strong&gt; Your environment doesn't crash. Pages don't break. URLs don't 404. Adobe simply stops applying platform updates. Days later, weeks later, the next CVE drops on a dependency Adobe usually patches for you, and your environment doesn't get the fix. Teams typically discover this during the &lt;em&gt;next&lt;/em&gt; security audit, not at the moment of failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Deprecated API List
&lt;/h2&gt;

&lt;p&gt;This is the part most blog posts hand-wave. Adobe publishes the list at the public &lt;a href="https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/release-notes/deprecated-removed-features" rel="noopener noreferrer"&gt;Deprecated and Removed Features&lt;/a&gt; page (no paywall, despite what some second-hand summaries imply), and the &lt;a href="https://javadoc.io/doc/com.adobe.aem/aem-sdk-api" rel="noopener noreferrer"&gt;aem-sdk-api javadoc&lt;/a&gt; carries the per-class &lt;code&gt;@Deprecated&lt;/code&gt; markers.&lt;/p&gt;

&lt;p&gt;Here's the cohort that's in the Feb 26 / Jun 11 wave, weighted by what's most likely to actually be in your codebase:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package family&lt;/th&gt;
&lt;th&gt;Why it's deprecated&lt;/th&gt;
&lt;th&gt;Replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;com.google.common.*&lt;/code&gt; (Guava)&lt;/td&gt;
&lt;td&gt;Adobe is removing the Guava export from the AEM platform classpath&lt;/td&gt;
&lt;td&gt;JDK collections / Apache Commons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.felix.http.whiteboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by the standard OSGi spec&lt;/td&gt;
&lt;td&gt;&lt;code&gt;org.osgi.service.http.whiteboard&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;org.eclipse.jetty.*&lt;/code&gt; (24 sub-packages)&lt;/td&gt;
&lt;td&gt;Adobe runs its own HTTP layer on AEMaaCS&lt;/td&gt;
&lt;td&gt;OSGi Http Whiteboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.felix.webconsole&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web console is platform-managed on AEMaaCS — apps shouldn't bundle it&lt;/td&gt;
&lt;td&gt;(Remove the dependency)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;org.slf4j.spi&lt;/code&gt;, &lt;code&gt;org.slf4j.event&lt;/code&gt;, &lt;code&gt;org.apache.log4j&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Logging is platform-managed; old SLF4J / Log4J 1.x APIs are out&lt;/td&gt;
&lt;td&gt;SLF4J 2.x public API or Log4J 2.x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ch.qos.logback.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same — platform owns logging&lt;/td&gt;
&lt;td&gt;SLF4J&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.sling.commons.auth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced upstream&lt;/td&gt;
&lt;td&gt;Sling Auth Core / Auth Core SPI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;com.mongodb.*&lt;/code&gt; (40+ packages)&lt;/td&gt;
&lt;td&gt;Mongo isn't supported on AEMaaCS&lt;/td&gt;
&lt;td&gt;Remove — use AEM-native repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;com.drew&lt;/code&gt; (metadata-extractor)&lt;/td&gt;
&lt;td&gt;Adobe-managed asset pipeline&lt;/td&gt;
&lt;td&gt;Use AEM Assets APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.jackrabbit.oak.plugins.memory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Internal Oak — never was public API&lt;/td&gt;
&lt;td&gt;Public Oak API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;org.apache.cocoon.xml&lt;/code&gt;, &lt;code&gt;org.apache.abdera.*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Retired upstream projects&lt;/td&gt;
&lt;td&gt;Replace with current libs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Top single-team breakers&lt;/strong&gt;, ranked by my read of likely incidence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Guava (&lt;code&gt;com.google.common.*&lt;/code&gt;).&lt;/strong&gt; Pulled in transitively by countless dependencies — older versions of &lt;a href="https://github.com/Adobe-Consulting-Services/acs-aem-commons" rel="noopener noreferrer"&gt;ACS AEM Commons&lt;/a&gt;, older versions of HTL helpers, internal utilities written before the JDK collections caught up. Searching across an AEM monorepo for &lt;code&gt;import com.google.common&lt;/code&gt; will usually surface dozens of hits in legitimate utility code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jetty (&lt;code&gt;org.eclipse.jetty.*&lt;/code&gt;).&lt;/strong&gt; Direct usage is rare, but bundled servlet adapters, embedded test harnesses, and old OSGi HTTP service consumers pick it up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logback (&lt;code&gt;ch.qos.logback.*&lt;/code&gt;).&lt;/strong&gt; Older custom appenders, especially those written before AEM 6.5, often reach into Logback specifics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apache Felix Web Console.&lt;/strong&gt; Apps that exposed custom plugins to /system/console are the affected ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's a longer-runway extended cohort (&lt;code&gt;org.bson.*&lt;/code&gt;, &lt;code&gt;org.apache.tika.*&lt;/code&gt; (80+ packages), &lt;code&gt;org.apache.commons.lang&lt;/code&gt; → Lang3, &lt;code&gt;org.apache.commons.collections&lt;/code&gt; → Collections4) that isn't in the Feb 26 wave but is queued up. If you're auditing now, audit those too — you'll save a second sweep.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Find Your Usage
&lt;/h2&gt;

&lt;p&gt;Adobe ships the &lt;a href="https://github.com/adobe/aemanalyser-maven-plugin" rel="noopener noreferrer"&gt;AEM Analyser Maven Plugin&lt;/a&gt; (&lt;code&gt;com.adobe.aem:aemanalyser-maven-plugin&lt;/code&gt;, current as of v1.6.16+). Run locally before you let CI surprise you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mvn clean verify &lt;span class="nt"&gt;-pl&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output line you care about looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[WARNING] Usage of deprecated package found : org.apache.commons.lang : Commons Lang 2 is in maintenance mode. Commons Lang 3 should be used instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adobe's own guidance is to "treat analyzer warnings as future pipeline failures." That's the right framing. Anything the plugin flags today as a warning will be a build failure on April 14.&lt;/p&gt;

&lt;p&gt;For environments where you can't easily run Maven locally — partner CI, infrastructure-as-code-only teams — the Cloud Manager Artifact Preparation step logs an &lt;code&gt;"Analyser warnings have been found"&lt;/code&gt; line. If you see that in your build logs and you've been ignoring it, this is the moment to stop ignoring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens at the Pipeline Level When You Don't Migrate
&lt;/h2&gt;

&lt;p&gt;The Code Quality step on April 14+ produces something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR] Build step failed with the following message:
  Code Quality - The following deprecated APIs are in use:
    org.apache.felix.http.whiteboard.HttpWhiteboardConstants  (3 occurrences in com.example.aem.servlets)
    com.google.common.collect.ImmutableList                   (47 occurrences across 12 modules)
  Deployment is blocked. Migrate these APIs and re-run the pipeline.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the loud surface. The quiet surface is what happens in the AEM environment itself when you don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bundle resolution succeeds today because the deprecated packages still resolve from the platform.&lt;/li&gt;
&lt;li&gt;Eventually (release-by-release), Adobe will stop exporting some of these packages from the AEM platform's &lt;code&gt;Export-Package&lt;/code&gt;. When that lands, your bundle goes into Installed-but-unresolved state.&lt;/li&gt;
&lt;li&gt;AEM will keep running. Your bundle will be inactive. The components that depend on it will fail to render — but only the affected paths. Customers see broken pages on specific URLs, not site-wide outages. Detection lag is high.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Migration: What's Actually Drop-In and What's Not
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From&lt;/th&gt;
&lt;th&gt;To&lt;/th&gt;
&lt;th&gt;Drop-in?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Guava &lt;code&gt;ImmutableList.of(...)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;List.of(...)&lt;/code&gt; (JDK 9+)&lt;/td&gt;
&lt;td&gt;Mostly. &lt;code&gt;Collectors.toUnmodifiableList()&lt;/code&gt; for streams.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guava &lt;code&gt;Strings.isNullOrEmpty&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;`s == null \&lt;/td&gt;
&lt;td&gt;\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guava {% raw %}&lt;code&gt;Maps.newHashMap()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new HashMap&amp;lt;&amp;gt;()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guava &lt;code&gt;Cache&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Caffeine&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — different API surface; refactor required.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache Commons Lang 2&lt;/td&gt;
&lt;td&gt;Lang 3&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — package change &lt;code&gt;org.apache.commons.lang&lt;/code&gt; → &lt;code&gt;org.apache.commons.lang3&lt;/code&gt;. Mass import rewrite.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;org.apache.felix.http.whiteboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;org.osgi.service.http.whiteboard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — both annotation-based and programmatic registration shapes change.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ch.qos.logback.*&lt;/code&gt; direct use&lt;/td&gt;
&lt;td&gt;SLF4J&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; if you're using Logback-specific features (encoders, custom appenders).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLF4J 1.x SPI&lt;/td&gt;
&lt;td&gt;SLF4J 2.x&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — &lt;code&gt;LoggerFactory.getLogger()&lt;/code&gt; works the same, but provider/MDC SPIs changed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What to Google If You're Already Past the Cliff
&lt;/h2&gt;

&lt;p&gt;If you only find this article &lt;em&gt;after&lt;/em&gt; June 11, the symptoms in your environment look like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud Manager Actions Center: &lt;strong&gt;"AEM version update paused"&lt;/strong&gt; or &lt;strong&gt;"environment is no longer receiving AEM updates."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Pipeline failure: &lt;strong&gt;"Build step failed: Code Quality - The following deprecated APIs are in use"&lt;/strong&gt; (this is the April 14 onwards mode).&lt;/li&gt;
&lt;li&gt;Bundle resolution failure log lines referencing &lt;code&gt;com.google.common&lt;/code&gt;, &lt;code&gt;org.eclipse.jetty.*&lt;/code&gt;, &lt;code&gt;ch.qos.logback&lt;/code&gt;, or &lt;code&gt;org.apache.felix.http.whiteboard&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Custom servlets returning 404 where they used to return 200 (Whiteboard registration silently dropped).&lt;/li&gt;
&lt;li&gt;Logging output reduced or empty for components that wired Logback directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix path is the same regardless of when you discover it: run the Analyser, fix the flagged usages, ship a successful pipeline run, regain platform updates. Cloud Manager picks back up automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern, Beyond AEM
&lt;/h2&gt;

&lt;p&gt;This release shape — multi-step escalation from email, to pause, to override, to fail, to silent un-patching — isn't unique to Adobe. It's the same pattern you see in Microsoft 365 Message Center retirements, in long Salesforce sunset cycles, in Stripe API version moves with brownouts.&lt;/p&gt;

&lt;p&gt;What's interesting about it from a monitoring angle is that &lt;em&gt;every&lt;/em&gt; surface in the escalation is silent-fail to &lt;em&gt;some&lt;/em&gt; part of the team. The Jan email reaches deploy-active environments only. The Feb pause is overridable by approver roles that may not be the migration owner. The Apr failure is deploy-time, not runtime. The May/June cutoffs are about &lt;em&gt;future&lt;/em&gt; updates, not current functionality. There is no single point at which a team is &lt;em&gt;forced&lt;/em&gt; to know.&lt;/p&gt;

&lt;p&gt;The only durable defense is to be watching for this pattern continuously — analyzer output in CI, deprecated-API counts trending over time, the Actions Center for environments that haven't deployed recently. It's the same observation that makes API response-shape monitoring useful at the runtime layer: you can't trust the changelog to reach the engineer who needs to act, and you can't trust the build to fail in the right place at the right time. Some layer needs to be watching, on a schedule, with alerts that route to the people who'd actually do the migration.&lt;/p&gt;

&lt;p&gt;That's the work I've been doing on the runtime side at &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; — watching API response shapes and flagging structural drift before dashboards go empty. The build-time analog for AEM is already in your tree (the Adobe Analyser plugin); the question is whether anyone is looking at the warnings.&lt;/p&gt;

&lt;p&gt;If you're an AEMaaCS team and June 11 is on your calendar with a question mark next to it, run &lt;code&gt;mvn clean verify&lt;/code&gt; against the latest aemanalyser plugin tonight. Anything red is a deploy block on April 14 — which is closer than it sounds.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've already hit one of these on a real environment — especially the override-and-forget path or the long-tail-environment path where you got no email — I'd genuinely like to compare notes. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aem</category>
      <category>adobe</category>
      <category>java</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Cloudflare TypeScript SDK v6 Made 133 Methods Return null and Empty Bodies Return undefined — Here's What Broke</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 10 May 2026 04:09:04 +0000</pubDate>
      <link>https://dev.to/flarecanary/cloudflare-typescript-sdk-v6-made-133-methods-return-null-and-empty-bodies-return-undefined--327m</link>
      <guid>https://dev.to/flarecanary/cloudflare-typescript-sdk-v6-made-133-methods-return-null-and-empty-bodies-return-undefined--327m</guid>
      <description>&lt;p&gt;On April 30, 2026, Cloudflare shipped &lt;code&gt;cloudflare-typescript&lt;/code&gt; v6.0.0. The release notes call it a "major version" with "breaking changes to the generated API surface" — accurate but understated. Two specific changes in the SDK infrastructure section will silently break code that compiled and tested fine on v5:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;133 methods now return &lt;code&gt;null&lt;/code&gt; instead of a typed response object.&lt;/strong&gt; Most are deletes, but the list also includes some &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, and &lt;code&gt;get&lt;/code&gt; operations across &lt;code&gt;accounts&lt;/code&gt;, &lt;code&gt;cache&lt;/code&gt;, &lt;code&gt;d1&lt;/code&gt;, &lt;code&gt;filters&lt;/code&gt;, &lt;code&gt;firewall&lt;/code&gt;, &lt;code&gt;hyperdrive&lt;/code&gt;, &lt;code&gt;iam&lt;/code&gt;, &lt;code&gt;kv&lt;/code&gt;, &lt;code&gt;logpush&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;r2&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;workers&lt;/code&gt;, &lt;code&gt;zero-trust&lt;/code&gt;, and &lt;code&gt;zones&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responses with &lt;code&gt;content-length: 0&lt;/code&gt; now return &lt;code&gt;undefined&lt;/code&gt; instead of attempting to parse the body.&lt;/strong&gt; Anywhere the server returned an empty 200/204, the SDK used to hand you back an empty object. Now you get &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are documented. Neither is loud at runtime. If you &lt;code&gt;npm install cloudflare@latest&lt;/code&gt; (or have Dependabot auto-bumping you), the surface looks the same — same import paths, same method names, same TypeScript types in your IDE. The runtime objects are just shaped differently than before.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;Direct from the &lt;a href="https://github.com/cloudflare/cloudflare-typescript/releases/tag/v6.0.0" rel="noopener noreferrer"&gt;v6.0.0 changelog&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Empty response handling&lt;/strong&gt;: Responses with &lt;code&gt;content-length: 0&lt;/code&gt; now return &lt;code&gt;undefined&lt;/code&gt; instead of attempting to parse the body. This may affect code that expected an empty object or null.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;133 methods now return &lt;code&gt;null&lt;/code&gt;&lt;/strong&gt; instead of a typed response object. This affects delete operations, some create/update operations, and several get operations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The example they give:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (v5)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AccountDeleteResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After (v6)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&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;Plus a third change worth flagging:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Retry-After handling changed&lt;/strong&gt;: The SDK now respects any server-specified &lt;code&gt;Retry-After&lt;/code&gt; value for rate-limited requests. Previously, values over 60 seconds were ignored and a default backoff was used instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If Cloudflare hands you a &lt;code&gt;Retry-After: 3600&lt;/code&gt; during an incident, your client now actually waits an hour. Pre-v6 it would have ignored anything over 60s and used the default backoff. CI and production code paths that assumed bounded retries can now hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent-Fail Surfaces
&lt;/h2&gt;

&lt;p&gt;This release is interesting because the failures don't look like failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 1 — &lt;code&gt;result.id&lt;/code&gt; on a deleted resource.&lt;/strong&gt; Common pattern across Cloudflare-using codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Deleted bucket &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// v5: logs the bucket id&lt;/span&gt;
&lt;span class="c1"&gt;// v6: TypeError: Cannot read properties of null (reading 'id')&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loud-ish — you'll see this in logs eventually. But if it's behind an error boundary, in a try/catch that swallows, or in a fire-and-forget cleanup path, it's quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 2 — &lt;code&gt;if (result)&lt;/code&gt; truthiness checks for success confirmation.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;namespaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespaceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;account_id&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespaceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;namespaceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KV delete failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On v5, &lt;code&gt;result&lt;/code&gt; was a typed response object — truthy. On v6, &lt;code&gt;result&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; — falsy. Every successful KV delete now flows down the failure branch. Your alerting fires on green operations. Your dashboard says everything's broken. Your runbook says "we just deleted half our namespaces by mistake" — but actually no, the deletes succeeded, your detection is just inverted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 3 — empty-body responses in retry/idempotency code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A bunch of Cloudflare endpoints reply 200 with &lt;code&gt;content-length: 0&lt;/code&gt; for idempotent no-op operations. Pre-v6 the SDK gave you &lt;code&gt;{}&lt;/code&gt;. Post-v6, &lt;code&gt;undefined&lt;/code&gt;. Code like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zones&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purgeCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;files&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;purgedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// v5: response is {}, response.timestamp is undefined, fallback fires.&lt;/span&gt;
&lt;span class="c1"&gt;// v6: response is undefined, response.timestamp throws.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you destructure or chain off the response without optional-chaining, this surfaces as &lt;code&gt;Cannot read properties of undefined (reading 'timestamp')&lt;/code&gt;. If you use &lt;code&gt;response?.timestamp&lt;/code&gt;, it works on both — but lots of older Cloudflare SDK code pre-dates the optional-chaining habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 4 — Retry-After unbounded waits.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your job runner has a hard timeout (CI minute caps, Lambda 15-minute ceiling, Cloudflare Workers' 30-second CPU budget) and Cloudflare returns &lt;code&gt;Retry-After: 1800&lt;/code&gt; during a degraded period, v6 will park your call for 30 minutes. The job times out, the alert is "function execution exceeded timeout" — which sends ops down a totally wrong rabbit hole. The fix is to set a max retry delay in your client config, but the v5 client did that for you implicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Surface 5 — &lt;code&gt;CLOUDFLARE_API_TOKEN=""&lt;/code&gt; is now unset.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// .env loader sets CLOUDFLARE_API_TOKEN to empty string when the var is missing-but-defined&lt;/span&gt;
&lt;span class="c1"&gt;// v5: SDK uses the empty string, the API rejects with 401 (loud).&lt;/span&gt;
&lt;span class="c1"&gt;// v6: SDK treats it as unset, falls back to "no auth", request fires unauthenticated.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same outcome — 401 from the API — but the path through the SDK is different, and any code that introspected the client to see "is auth configured" now reports "not configured" where it used to report "configured but empty."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TypeScript Doesn't Save You
&lt;/h2&gt;

&lt;p&gt;The interesting bit: TypeScript &lt;em&gt;does&lt;/em&gt; save you, but only on a fresh codebase. If you upgrade in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;result: null&lt;/code&gt; change is a type narrowing. Code that types &lt;code&gt;result&lt;/code&gt; as &lt;code&gt;AccountDeleteResponse | null&lt;/code&gt; and then accesses &lt;code&gt;result.id&lt;/code&gt; is a compile error in strict mode. &lt;strong&gt;But&lt;/strong&gt; lots of consumers don't pin the response type at all — they let inference do the work. Inference picks up the new &lt;code&gt;null&lt;/code&gt; type. Code that was &lt;code&gt;result.id&lt;/code&gt; is still &lt;code&gt;result.id&lt;/code&gt; after recompile, but now it's a type error.&lt;/li&gt;
&lt;li&gt;Most teams handle the type error with the path of least resistance: &lt;code&gt;result?.id&lt;/code&gt; or &lt;code&gt;(result as any).id&lt;/code&gt;. Once that lands, the runtime behavior is "log undefined" or "throw on a tighter access" — and the underlying bug (treating success as failure, treating empty body as parsed object) is still there.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;content-length: 0&lt;/code&gt; → &lt;code&gt;undefined&lt;/code&gt; change has &lt;em&gt;no&lt;/em&gt; type change to flag it. The return type of &lt;code&gt;purgeCache&lt;/code&gt; is the same. The runtime value silently shifts from &lt;code&gt;{}&lt;/code&gt; to &lt;code&gt;undefined&lt;/code&gt;. Nothing in tsc will catch it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Retry-After change has no surface in the type system at all. It's a behavioral change inside the retry interceptor.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Find If You're Affected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Pin or unpin deliberately.&lt;/strong&gt; If you're on v5.x and not ready to audit, pin &lt;code&gt;cloudflare@^5&lt;/code&gt;. If you've already moved to v6, do the audit — don't sit in the middle. Dependabot/Renovate will not flag this for you because the major version bump is "expected breaking change."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Grep for the patterns.&lt;/strong&gt; In your repo:&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;# Likely-broken: accessing fields on results from likely-now-null methods&lt;/span&gt;
rg &lt;span class="s2"&gt;"client&lt;/span&gt;&lt;span class="se"&gt;\.\w&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;delete&lt;/span&gt;&lt;span class="se"&gt;\(&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;+"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; ts
rg &lt;span class="s2"&gt;"(result|response|deleted)&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;(id|name|success)"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; ts
&lt;span class="c"&gt;# Truthiness checks on delete results&lt;/span&gt;
rg &lt;span class="s2"&gt;"const &lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="s2"&gt;+ = await client&lt;/span&gt;&lt;span class="se"&gt;\.\w&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;delete"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; ts &lt;span class="nt"&gt;-A&lt;/span&gt; 3 | rg &lt;span class="s2"&gt;"if &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Test against the live API.&lt;/strong&gt; Spin up an integration test that does an actual delete in a Cloudflare staging account, asserts on the return shape. Pre-v6, the response is an object. Post-v6, it's &lt;code&gt;null&lt;/code&gt;. If your test was just "did the call resolve without throwing," it passes both sides — and that's the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Add a runtime shape check on critical Cloudflare calls.&lt;/strong&gt; This is the durable fix. The contract you depend on isn't "this API call succeeds" — it's "this API call returns the structure we expect." Watching the response shape over time catches changes the SDK release notes might bury, future SDK majors might re-introduce, or the API itself might shift independent of the SDK.&lt;/p&gt;

&lt;p&gt;That last point is what I've been building at &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt;. The Cloudflare v6 release isn't unique — it's the fourth or fifth major API surface I've watched make this kind of "loud-in-the-changelog, silent-at-runtime" shift in the last six weeks. Stripe's Dahlia release reshaped &lt;code&gt;decimal_string&lt;/code&gt; fields from strings to typed Decimals. Microsoft stripped &lt;code&gt;OldValue&lt;/code&gt;/&lt;code&gt;NewValue&lt;/code&gt; from Dataverse audit events going to Purview. GitHub silently removed &lt;code&gt;payload.commits&lt;/code&gt; from PushEvent. The pattern is the same: the changelog is honest, the SDK type changes if you read them, but the &lt;em&gt;runtime&lt;/em&gt; shifts under code that compiled fine.&lt;/p&gt;

&lt;p&gt;The honest defense is to monitor the response shapes you depend on. Either roll your own — cron a script that calls your top N Cloudflare endpoints, hashes the response shape, diffs against a baseline — or let something like FlareCanary do it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Pattern
&lt;/h2&gt;

&lt;p&gt;Cloudflare's v6 changelog is &lt;em&gt;good&lt;/em&gt;. The breaking changes are listed, the migration paths are documented, the deprecations are marked. Honestly better than most major API releases.&lt;/p&gt;

&lt;p&gt;And it's still possible to be silently wrong after upgrading.&lt;/p&gt;

&lt;p&gt;The reason is that runtime semantics aren't fully captured in type signatures. "This method now returns null" is a type change that strict-mode TypeScript can catch. "Empty bodies now parse to undefined" isn't — the return type is whatever it was before, the runtime value just shifted. "Retry-After is now respected up to any value" isn't a type change at all. None of these surfaces will fail an &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The v6 release is also a useful reminder of how &lt;em&gt;much&lt;/em&gt; of the Cloudflare API surface is now in this SDK — 106 resource sections, 885 source files. It's effectively impossible to review every method's behavior change manually. You can read the changelog, you can run your test suite, and you can still be surprised in production.&lt;/p&gt;

&lt;p&gt;That's the gap response-shape monitoring fills. Status-200 plus expected structure plus expected types is a stronger signal than "the call returned without throwing." And it travels with you across SDK majors.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you upgraded to cloudflare-typescript v6 and got bitten by one of these — especially the truthiness flip on delete results — I'd love to hear about it. Replies below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>typescript</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>GitHub Silently Removed payload.commits From PushEvent — Here's What Broke and How to Catch the Next One</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 10 May 2026 04:04:21 +0000</pubDate>
      <link>https://dev.to/flarecanary/github-silently-removed-payloadcommits-from-pushevent-heres-what-broke-and-how-to-catch-the-2i33</link>
      <guid>https://dev.to/flarecanary/github-silently-removed-payloadcommits-from-pushevent-heres-what-broke-and-how-to-catch-the-2i33</guid>
      <description>&lt;p&gt;On October 7, 2025, GitHub stripped a bunch of fields out of the Events API without changing a version number. The &lt;code&gt;commits&lt;/code&gt; array on &lt;code&gt;PushEvent&lt;/code&gt;. The &lt;code&gt;author&lt;/code&gt; name and email. &lt;code&gt;author_association&lt;/code&gt; on issue/PR/review/comment events. All gone.&lt;/p&gt;

&lt;p&gt;No HTTP error. No deprecation warning at request time. No API version bump. The endpoint still returned &lt;code&gt;200 OK&lt;/code&gt;. The JSON was still valid. The shape was just different than it used to be.&lt;/p&gt;

&lt;p&gt;If you had a CI hook, an abuse-detection pipeline, a dashboard, an internal tool — anything that read &lt;code&gt;PushEvent.payload.commits&lt;/code&gt; — it started returning &lt;code&gt;undefined&lt;/code&gt; overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Changed
&lt;/h2&gt;

&lt;p&gt;From GitHub's &lt;a href="https://github.blog/changelog/2025-08-08-upcoming-changes-to-github-events-api-payloads/" rel="noopener noreferrer"&gt;August 8 changelog&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;PushEvent&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;payload.commits[]&lt;/code&gt; — &lt;strong&gt;removed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Commit SHAs, author names, author emails, commit messages — &lt;strong&gt;all gone&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;code&gt;IssuesEvent&lt;/code&gt;, &lt;code&gt;PullRequestEvent&lt;/code&gt;, &lt;code&gt;IssueCommentEvent&lt;/code&gt;, &lt;code&gt;PullRequestReviewEvent&lt;/code&gt;, &lt;code&gt;PullRequestReviewCommentEvent&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;author_association&lt;/code&gt; — &lt;strong&gt;removed&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitHub ran a "brownout" test on September 8, 2025 — one day where the fields were pulled, then restored — and then made the removal permanent in October. The stated reason was abuse: scrapers were using the Events API to harvest commit metadata at scale. Fair enough. But from the consumer side, the surface looked like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (September):&lt;/strong&gt;&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;"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;"PushEvent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;"commits"&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="nl"&gt;"sha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a1b2c3..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"author"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jane"&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;"jane@example.com"&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;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fix tokenizer"&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;&lt;strong&gt;After (October):&lt;/strong&gt;&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;"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;"PushEvent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payload"&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;Still 200 OK. Still valid JSON. Just silently missing the thing a lot of tooling was reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nobody's Tests Caught It
&lt;/h2&gt;

&lt;p&gt;This is the interesting part. Let's walk through why the usual safety nets didn't trip:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests didn't catch it&lt;/strong&gt; because unit tests use fixtures, and fixtures are frozen in time. The test data had &lt;code&gt;commits&lt;/code&gt;; production no longer did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests didn't catch it&lt;/strong&gt; unless they were running against the live GitHub API &lt;em&gt;and&lt;/em&gt; asserting on the shape of the response. Most integration tests assert on behavior ("does our system process a push event?"), not structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript didn't catch it&lt;/strong&gt; because TypeScript can't catch what it can't see. The field type is still defined in your Octokit types. The runtime object just doesn't have the field. Your code happily accesses &lt;code&gt;payload.commits&lt;/code&gt; and gets &lt;code&gt;undefined&lt;/code&gt;, then calls &lt;code&gt;.map()&lt;/code&gt; on it and throws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API version didn't change&lt;/strong&gt; because GitHub's Events API isn't versioned the way REST APIs with dated versions are. There was no pinned version to stay on. Consumers who wanted the old shape didn't have that option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error monitoring didn't flag it early&lt;/strong&gt; because for a lot of code paths, the failure mode wasn't an exception — it was empty output. Your abuse detector processed the event, saw no commits, and marked the user clean. Your dashboard showed a zero. Your pipeline ran through the "empty case" branch.&lt;/p&gt;

&lt;p&gt;Here's what showed up in &lt;a href="https://github.com/orgs/community/discussions/177111" rel="noopener noreferrer"&gt;GitHub Community Discussion #177111&lt;/a&gt; after the change landed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"This is a silent breaking change. Our abuse-detection pipeline has been running on empty events for a week and we only noticed because a nightly job alerted on throughput being anomalously low."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the worst version of a schema change. Not a crash. Not an alert. Just quietly wrong data flowing through your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The General Pattern
&lt;/h2&gt;

&lt;p&gt;This isn't a GitHub problem. It's an API surface problem, and it happens constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe's 2025-03-31 "Basil" release&lt;/strong&gt; removed &lt;code&gt;billing_thresholds&lt;/code&gt; from subscriptions and killed the Upcoming Invoice API outright. Teams that had moved their account default version without re-pinning webhooks got silently migrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plaid's May 2025 changes&lt;/strong&gt; renamed &lt;code&gt;zip&lt;/code&gt; to &lt;code&gt;postal_code&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt; to &lt;code&gt;region&lt;/code&gt;, and flipped some empty-string fields to &lt;code&gt;null&lt;/code&gt;. Anything doing &lt;code&gt;.trim()&lt;/code&gt; on those fields stopped working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI's Responses API&lt;/strong&gt; exposed per-turn shape variance — &lt;code&gt;reasoning&lt;/code&gt; appears and disappears depending on whether a tool was called — which static typing can't model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common thread: the API provider has legitimate reasons to change the shape (abuse mitigation, data correctness, new capabilities). The consumer's tests assume a frozen structure. The gap between those two realities is where production breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Catch This
&lt;/h2&gt;

&lt;p&gt;There are three honest defenses, in order of how much they actually help:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pin what you can pin.&lt;/strong&gt; Stripe, OpenAI, Shopify — these APIs offer explicit version headers. Use them. Don't move forward without a deliberate upgrade. This doesn't help for GitHub's Events API (no versioning) but it helps everywhere it's available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Assert on structure in integration tests.&lt;/strong&gt; Not just "did we process the event" but "does the payload have the field we rely on." This catches the problem in CI instead of prod — but only if your tests actually run against live endpoints regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Monitor the response shape in production.&lt;/strong&gt; This is the one most teams skip. Poll the endpoints you depend on (or sample live traffic), record the structure over time, and diff against a learned baseline. When a field disappears or changes type, you get an alert &lt;em&gt;before&lt;/em&gt; your dashboards go empty.&lt;/p&gt;

&lt;p&gt;The third defense is what I've been building at &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt;. Point it at your critical endpoints — the ones whose schema changes would make your Monday morning terrible — and it polls them on a schedule, learns the expected structure, and flags drift. Removed fields, type shifts, nullability changes, new fields that might signal a migration. Severity-classified so a new optional field is informational and a removed field is an alert.&lt;/p&gt;

&lt;p&gt;You don't strictly need a tool for this. You can cron a script that calls your top 5 endpoints, hashes the field set, and diffs. The point is that &lt;em&gt;some&lt;/em&gt; layer needs to be watching the shape, not just the status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Harder Question
&lt;/h2&gt;

&lt;p&gt;The thing the GitHub Events change really surfaces is this: &lt;em&gt;how many of the APIs your service depends on actually have a team watching their response shape?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most teams I've talked to know their dependency graph at the package level. They can tell you what version of Stripe's SDK they use, what OpenAI model they call, what GitHub endpoints they hit. Almost none of them can tell you whether the response from those endpoints has changed structure in the last month.&lt;/p&gt;

&lt;p&gt;That's the monitoring gap. HTTP status codes tell you an endpoint is up. Response times tell you it's fast. Neither tells you the data contract is still what you thought.&lt;/p&gt;

&lt;p&gt;If any of the Events API consumers mentioned in the community threads had been diffing &lt;code&gt;/events&lt;/code&gt; responses against a baseline, they'd have caught the September 8 brownout and had a full month's warning before the permanent cut. The capability to catch it existed. The habit to watch for it didn't.&lt;/p&gt;

&lt;p&gt;That's the real lesson, and it applies to every API you don't control.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've been hit by an API schema change that slipped through your tests, I'd genuinely like to hear about it — especially the "empty output, no error" variety. Replies below, or hit me up if you want to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>api</category>
      <category>webdev</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Microsoft Stripped OldValue/NewValue From Dataverse Audit Events Going to Purview on May 1 — Anomaly Rules Now See Nothing</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 09 May 2026 04:11:18 +0000</pubDate>
      <link>https://dev.to/flarecanary/microsoft-stripped-oldvaluenewvalue-from-dataverse-audit-events-going-to-purview-on-may-1--45h9</link>
      <guid>https://dev.to/flarecanary/microsoft-stripped-oldvaluenewvalue-from-dataverse-audit-events-going-to-purview-on-may-1--45h9</guid>
      <description>&lt;p&gt;If you run anomaly detection or DLP correlation on Microsoft Purview audit events sourced from Dataverse, your rules went silent on May 1, 2026.&lt;/p&gt;

&lt;p&gt;The events still arrive. The row counts in Purview are the same. The activity dashboards look identical. Every audit envelope continues to ship the metadata you'd expect — actor, timestamp, table, action type, record ID. The only thing missing is the part most security teams were actually using.&lt;/p&gt;

&lt;p&gt;The before-and-after field values are gone.&lt;/p&gt;

&lt;p&gt;This is Microsoft 365 Message Center post &lt;strong&gt;MC1239891&lt;/strong&gt;: &lt;a href="https://m365admin.handsontek.net/power-platform-information-regarding-removal-field-level-value-changes-audit-events-sent-microsoft-purview/" rel="noopener noreferrer"&gt;"Information regarding removal of field-level value changes in audit events sent to Microsoft Purview"&lt;/a&gt;. Effective May 1, 2026. Field-level &lt;code&gt;OldValue&lt;/code&gt; / &lt;code&gt;NewValue&lt;/code&gt; payloads are stripped from Dataverse audit events as they cross into the Purview unified pipeline.&lt;/p&gt;

&lt;p&gt;Microsoft frames it as a privacy improvement, and they're not wrong — Purview-side consumers (SIEM forwarders, third-party connectors, e-discovery tooling) were aggregating sensitive PII into a place where the access controls weren't necessarily as tight as the originating Dataverse environment. Stripping the field values at the boundary closes that.&lt;/p&gt;

&lt;p&gt;The cost of that improvement, though, lands on every team whose detection logic was built on top of those values.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Still There vs. What's Gone
&lt;/h2&gt;

&lt;p&gt;What still flows to Purview after May 1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit row metadata&lt;/strong&gt; — &lt;code&gt;RecordType&lt;/code&gt;, &lt;code&gt;Operation&lt;/code&gt;, &lt;code&gt;UserId&lt;/code&gt;, &lt;code&gt;ObjectId&lt;/code&gt;, &lt;code&gt;CreationTime&lt;/code&gt;, &lt;code&gt;Workload&lt;/code&gt;, &lt;code&gt;OrganizationId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table and record identity&lt;/strong&gt; — entity name, record GUID, what was acted on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action type&lt;/strong&gt; — &lt;code&gt;Update&lt;/code&gt;, &lt;code&gt;Create&lt;/code&gt;, &lt;code&gt;Delete&lt;/code&gt;, &lt;code&gt;Access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field name list&lt;/strong&gt; — &lt;em&gt;which&lt;/em&gt; attributes changed (in many cases)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OldValue&lt;/code&gt;&lt;/strong&gt; — the value of each changed field before the update&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NewValue&lt;/code&gt;&lt;/strong&gt; — the value after&lt;/li&gt;
&lt;li&gt;Any nested change-detail payload that previously contained those values for Dataverse audit events specifically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Existing audit rows from before May 1 retain their original payload. Only newly-created audit events created after the cutover have the stripped shape. That makes the regression invisible to retrospective queries — any test you run against a "known good" audit record from April still passes. The new audits don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Looks Like Nothing Changed
&lt;/h2&gt;

&lt;p&gt;The reason this is a textbook silent failure has three parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Row counts don't move.&lt;/strong&gt; Audit volume in Purview is unchanged — every action that produced an event before still produces one. SIEMs that alert on "audit gap" or "logging stopped" don't trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dashboards still populate.&lt;/strong&gt; Microsoft Sentinel, Splunk, Chronicle, and the rest get their hourly Purview pull. The records arrive. The widgets refresh. The "audit activity" panels show the same line. There's no failure indicator at the platform level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The query language doesn't error.&lt;/strong&gt; Detection rules in KQL, SPL, etc. that referenced &lt;code&gt;OldValue&lt;/code&gt; or &lt;code&gt;NewValue&lt;/code&gt; don't return errors when the field is missing — they return &lt;em&gt;no rows&lt;/em&gt;. Empty result sets read as "no anomalies detected." Which is exactly the value those queries return when the world is fine. A query saying "alert when OldValue == 'Active' AND NewValue == 'Inactive' for AccountStatus" stops firing because the where-clause never matches anything. Nobody knows the rule went mute. They know is the alerts stopped, and "the alerts stopped" has a thousand benign-looking causes.&lt;/p&gt;

&lt;p&gt;If your compliance program is built on these correlations — privileged-field changes, status flips, balance adjustments, role escalations, recipient-redirections — the rules went silent five days before this article was written, and it's likely nobody has noticed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concrete Detections That Just Stopped Working
&lt;/h2&gt;

&lt;p&gt;A non-exhaustive list of rule patterns that depend on field-level deltas in Purview-sourced Dataverse events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status-flip detection.&lt;/strong&gt; Account flipping from &lt;code&gt;Active&lt;/code&gt; → &lt;code&gt;Inactive&lt;/code&gt; outside business hours. Lead flipped from &lt;code&gt;Disqualified&lt;/code&gt; → &lt;code&gt;Qualified&lt;/code&gt; without an approval workflow. Sales-stage regression. All of these are "where OldValue == X AND NewValue == Y" rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privileged-field watch.&lt;/strong&gt; Role membership changes, privilege grants, security-group membership flips on Dataverse identity records. The list of &lt;em&gt;what fields changed&lt;/em&gt; still reaches Purview; the &lt;em&gt;values themselves&lt;/em&gt; don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial guardrails.&lt;/strong&gt; Watching for credit-limit increases, discount-percentage bumps, payment-term extensions beyond a threshold. The delta — "increased from 30 to 90 days" — was the rule. The new event reports "PaymentTerm changed" with no values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PII redirection alerts.&lt;/strong&gt; A common pattern: alert when the email address on a contact record changes to a different domain than the prior value (used to catch impersonation / account-takeover). Both the prior and new domains were the rule. Both are gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk-edit anomaly.&lt;/strong&gt; "User edited 50 contact records in 5 minutes, where 80% of the changes set Status = 'Disqualified'" — required reading the resulting NewValue. Now the rule sees 50 changes; can't see what they were.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record-merge correlation.&lt;/strong&gt; Reconstructing what was kept and what was lost in a merge required the before/after on each field. Audit still shows the merge happened. The contents of the merge are no longer in Purview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance-comparison reporting.&lt;/strong&gt; Quarterly reports that compute "X% of contact records had their consent-flag flipped from Granted → Revoked" — these rolled up OldValue/NewValue from Purview. Reports go to zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are all rules that pre-date May 1, 2026 and were green on April 30. They'll stay green after May 1 because empty result sets don't generate alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CI / Validation Didn't Catch It
&lt;/h2&gt;

&lt;p&gt;The same shape of the problem we keep seeing on every silent breaking change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection rules don't have unit tests.&lt;/strong&gt; Most security-rule frameworks let you author KQL/SPL/Sigma but don't ship a way to assert "this rule produces N alerts when given this fixture." If you have such a test harness — congratulations, you're in the top 1% — but it's almost certainly using fixtures recorded &lt;em&gt;before&lt;/em&gt; May 1, when OldValue still existed. The tests pass against the old shape; production sees the new shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit volume is the canary, and the canary is fine.&lt;/strong&gt; Most teams monitor Purview ingestion volume. Volume didn't drop. The canary keeps singing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft's communication channel.&lt;/strong&gt; MC1239891 went into the Microsoft 365 Message Center. If your security architect doesn't read MC posts that look like they're for the Power Platform admin (because the dependency chain to Purview-side detection isn't visible from the post's title), the change lands without anyone hearing it. The post mentions Purview, but it's filed under Power Platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organizational seam.&lt;/strong&gt; Dataverse changes are owned by the Power Platform admin / D365 team. Purview detection rules are owned by the security team. The fact that a Power Platform configuration change blanks out a security team's detection logic crosses a domain boundary that no playbook usually covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Migration Path
&lt;/h2&gt;

&lt;p&gt;Microsoft's recommended workaround is correct, but it requires moving where your detections live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Pull from Dataverse Web API directly.&lt;/strong&gt; The before/after values are still stored in Dataverse — they didn't go anywhere. They are accessible via the &lt;a href="https://learn.microsoft.com/en-us/power-apps/developer/data-platform/auditing/retrieve-audit-data" rel="noopener noreferrer"&gt;&lt;code&gt;RetrieveAuditDetails&lt;/code&gt;&lt;/a&gt; API on the audit table. The flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit row (in Dataverse) → RetrieveAuditDetailsRequest →
  AttributeAuditDetail.OldValue / .NewValue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You build a connector that polls Dataverse audit on a schedule, pulls the audit-detail rows for changes you care about, and pushes the enriched events into your SIEM as a separate stream. This is more work than what existed before (Purview was doing the heavy lifting) and the data isn't in the same query store as the rest of your Purview data, so cross-table correlations get harder.&lt;/p&gt;

&lt;p&gt;The audit-detail API has a few footguns worth knowing in advance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large field values are &lt;strong&gt;truncated at 5 KB&lt;/strong&gt; and the response shows an ellipsis. Long-text fields (description, comments) can't be fully reconstructed.&lt;/li&gt;
&lt;li&gt;The user calling the API needs &lt;code&gt;prvReadAuditSummary&lt;/code&gt;. A service principal that worked for the Purview pull may not have this privilege on the Dataverse side.&lt;/li&gt;
&lt;li&gt;Audit data &lt;strong&gt;isn't accessible via the TDS / SQL endpoint&lt;/strong&gt;. You can't join it to other Dataverse tables through the SQL surface — has to be via the Web API or Organization Service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Option B: Switch to Dataverse-native detection.&lt;/strong&gt; Run the rules against the Dataverse audit table itself, via Power Automate flows or scheduled functions, and only forward the &lt;em&gt;result&lt;/em&gt; (a fired alert) to your SIEM. This keeps the field values inside the Dataverse compliance boundary, which is also Microsoft's preference — it's the privacy-preserving path. Trade-off: you lose centralization. Each Dataverse environment becomes its own detection point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: Accept the loss.&lt;/strong&gt; For a subset of rules where the &lt;em&gt;fact that a field changed&lt;/em&gt; is enough, drop the value-comparison clause and alert on any change to the watched field. This widens the alert volume considerably. It's a fallback, not a replacement.&lt;/p&gt;

&lt;p&gt;Whichever path you pick, the migration sequence that actually holds is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory.&lt;/strong&gt; Grep your detection content (Sentinel rules, Sigma packs, Splunk content, custom KQL) for &lt;code&gt;OldValue&lt;/code&gt; and &lt;code&gt;NewValue&lt;/code&gt;. Anything that came out of &lt;code&gt;Audit.General&lt;/code&gt; or Dataverse-sourced workloads is in scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triage.&lt;/strong&gt; Sort by criticality (privileged-field rules first), by event volume (low-volume rules first to reduce blast radius), and by whether the rule is "any change" (still works) vs. "specific value transition" (broken).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace.&lt;/strong&gt; Build the Dataverse-side pull or rewrite as Dataverse-native rules. Validate end-to-end against a known test transition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run both paths in parallel for a sprint.&lt;/strong&gt; Compare alert counts pre- and post-migration. Resolve gaps before tearing the old (now empty) rules out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update playbooks.&lt;/strong&gt; SOC runbooks that say "pivot to OldValue/NewValue in the Audit row" need to be rewritten to "pivot to the Dataverse &lt;code&gt;audit_audit_details&lt;/code&gt; link in the source environment."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How To See The Next One
&lt;/h2&gt;

&lt;p&gt;This pattern — &lt;em&gt;the data still flows, the shape just changed&lt;/em&gt; — is not unique to Microsoft. It's the most common breaking-change pattern this year, by a good margin. Stripe's Dahlia release reshaped decimal fields in the SDK. GitHub silently retired seven org-security fields. Power Platform stripped two attributes from a deeply nested payload. The HTTP envelope didn't move. The endpoint didn't 404. The thing inside changed.&lt;/p&gt;

&lt;p&gt;The defense is to watch the &lt;em&gt;shape&lt;/em&gt; of every external response your detections, integrations, and consumers depend on. Not the volume, not the latency, not the status code — the shape. The presence and type of every field, on every payload you read.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; plugs. Point it at the endpoints and event streams you depend on, and it learns the response structure, then alerts on field disappearances, type changes, and shape drifts. For a Purview/Dataverse setup this would have caught &lt;code&gt;OldValue&lt;/code&gt; / &lt;code&gt;NewValue&lt;/code&gt; going to &lt;code&gt;null&lt;/code&gt; (or being absent entirely) on the first event after May 1, before the silence reached the alerts.&lt;/p&gt;

&lt;p&gt;You don't strictly need a tool. You need &lt;em&gt;a habit&lt;/em&gt;. Watch the runtime shape of every external response you depend on. Detection rules built on top of fields are only as durable as the fields. The ones in MC1239891 vanished cleanly, with no error, on a quiet Friday in May. The next ones will too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your security tooling depends on Dataverse audit field values — or you've been bit by any silent shape-strip on a payload — drop a note. The "audit volume looks fine, the rules just stopped firing" failures are the exact kind we're tracking.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>microsoft</category>
      <category>security</category>
      <category>compliance</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Stripe's 2026-04-22 Dahlia Release Quietly Changed unit_amount_decimal From String to Decimal — Your `==` Checks Are Now Always False</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 09 May 2026 04:05:05 +0000</pubDate>
      <link>https://dev.to/flarecanary/stripes-2026-04-22-dahlia-release-quietly-changed-unitamountdecimal-from-string-to-decimal--17m7</link>
      <guid>https://dev.to/flarecanary/stripes-2026-04-22-dahlia-release-quietly-changed-unitamountdecimal-from-string-to-decimal--17m7</guid>
      <description>&lt;p&gt;On April 22, 2026, Stripe shipped the Dahlia API version. Among the changes was one that doesn't sound like much in the changelog and absolutely is in production: every field formatted as &lt;code&gt;decimal_string&lt;/code&gt; — &lt;code&gt;unit_amount_decimal&lt;/code&gt;, &lt;code&gt;quantity_decimal&lt;/code&gt;, &lt;code&gt;fx_rate&lt;/code&gt;, and friends — changed type in the SDKs.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;stripe-python&lt;/code&gt;, those fields used to be &lt;code&gt;str&lt;/code&gt;. They are now &lt;code&gt;decimal.Decimal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;stripe-node&lt;/code&gt;, they used to be &lt;code&gt;string&lt;/code&gt;. They are now &lt;code&gt;Stripe.Decimal&lt;/code&gt; — a class Stripe ships inside the SDK package.&lt;/p&gt;

&lt;p&gt;Same for &lt;code&gt;stripe-dotnet&lt;/code&gt;, &lt;code&gt;stripe-go&lt;/code&gt;, &lt;code&gt;stripe-java&lt;/code&gt;, &lt;code&gt;stripe-php&lt;/code&gt;, &lt;code&gt;stripe-ruby&lt;/code&gt;. Every official SDK got the same reshape.&lt;/p&gt;

&lt;p&gt;The wire format did &lt;strong&gt;not&lt;/strong&gt; change. Stripe's HTTP responses still serialize these fields as JSON strings. The SDKs now parse them into a typed Decimal on the way in, and serialize them back to strings on the way out. From the docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The SDK handles conversion to and from the string wire format transparently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence does a lot of heavy lifting. It is true on the request and response side. It is not true in any code path between those two boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Quietly Breaks
&lt;/h2&gt;

&lt;p&gt;If your code does any of the following with a &lt;code&gt;decimal_string&lt;/code&gt; field, it broke when you bumped the SDK to a Dahlia-compatible version. None of these will throw at SDK install time, none will fail unit tests that mock Stripe with the old shape, and none will produce a clean stack trace pointing back at the change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Equality checks against strings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line_item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unit_amount_decimal&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1500&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;apply_discount&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# After: line_item.unit_amount_decimal is Decimal('1500'), never == "1500"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Decimal("1500") == "1500"&lt;/code&gt; is &lt;code&gt;False&lt;/code&gt;. Always. No coercion, no warning. The &lt;code&gt;if&lt;/code&gt; branch stops firing. Whatever logic depended on it goes silently dead.&lt;/p&gt;

&lt;h3&gt;
  
  
  String operations
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node — was: "1500.00".startsWith("1") → true&lt;/span&gt;
&lt;span class="c1"&gt;// After: price.unitAmountDecimal is Stripe.Decimal — no .startsWith()&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;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitAmountDecimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// TypeError: price.unitAmountDecimal.startsWith is not a function&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python — was: "1500.00"[:2] → "15"
# After: Decimal('1500.00')[:2] → TypeError: 'decimal.Decimal' object is not subscriptable
&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line_item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unit_amount_decimal&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less-obvious variants: &lt;code&gt;len()&lt;/code&gt;, &lt;code&gt;.split('.')&lt;/code&gt;, regex matching. Anything that treated the field as a string.&lt;/p&gt;

&lt;h3&gt;
  
  
  JSON serialization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stripe_response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# TypeError: Object of type Decimal is not JSON serializable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one that bites the hardest in practice. Backends that &lt;code&gt;json.dumps()&lt;/code&gt; a Stripe object — to log it, to enqueue it on a job queue, to forward it to an analytics pipeline — start raising at the serialization boundary, not at the API call. The traceback points at the dumps line, not at Stripe.&lt;/p&gt;

&lt;p&gt;In Node, &lt;code&gt;JSON.stringify(price)&lt;/code&gt; doesn't throw, but it serializes the Decimal class via its &lt;code&gt;toJSON&lt;/code&gt; (if defined) or its default object representation. Either way the output shape changes from &lt;code&gt;"1500"&lt;/code&gt; to either &lt;code&gt;"1500"&lt;/code&gt; (if toJSON returns a string), or &lt;code&gt;{}&lt;/code&gt; / something object-shaped (if not). Downstream consumers that parse and re-validate may reject it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mixed-type arithmetic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python: was string, you'd cast first
&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unit_amount_decimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Now li.unit_amount_decimal is Decimal, li.quantity is int — mixing Decimal*int is fine
# But: total + 0.01 (a float) → TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Decimal + float&lt;/code&gt; raises in Python. So the moment your accumulator picks up a float anywhere in the chain — a hardcoded 0.01 for rounding, a return value from another library — addition explodes. The location of the explosion can be far from the SDK boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Database driver type expectations
&lt;/h3&gt;

&lt;p&gt;Most drivers handle Decimal cleanly (psycopg2, asyncpg, the .NET SQL drivers, JDBC). Some don't, especially older or thin drivers, NoSQL clients, and ORMs that do their own coercion. If you've been passing &lt;code&gt;unit_amount_decimal&lt;/code&gt; straight into a query parameter, you may now hit a different code path in the driver — one that's slower, or that converts to a different SQL type, or that silently truncates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Logging and observability tools
&lt;/h3&gt;

&lt;p&gt;Loggers that string-coerce values write &lt;code&gt;"Decimal('1500.00')"&lt;/code&gt; to the log line, not &lt;code&gt;"1500.00"&lt;/code&gt;. Search queries against your log index for the price value miss. Sentry breadcrumbs change shape. Datadog APM spans tagged with the price string lose dashboarding continuity. Nothing breaks loudly; the searches just stop matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wire-Format Trap
&lt;/h2&gt;

&lt;p&gt;Here is the silent-fail vector that has the highest blast radius.&lt;/p&gt;

&lt;p&gt;Stripe webhooks deliver raw JSON. If your handler parses the request body itself and accesses fields directly — &lt;code&gt;body["data"]["object"]["unit_amount_decimal"]&lt;/code&gt; — those fields are still strings. They came off the wire as strings, and you never went through the SDK's deserialization path.&lt;/p&gt;

&lt;p&gt;Stripe SDK retrievals — &lt;code&gt;stripe.Price.retrieve(...)&lt;/code&gt; or &lt;code&gt;stripe.checkout.Session.retrieve(...)&lt;/code&gt; — return Decimal-typed fields. Same field name, same logical value, different runtime type.&lt;/p&gt;

&lt;p&gt;So: in one part of your codebase, &lt;code&gt;unit_amount_decimal&lt;/code&gt; is a string. In another, it's a Decimal. Both came from "Stripe." Both are correct. Whether the equality check, the comparison, the arithmetic, or the serialization works depends on which codepath produced the value.&lt;/p&gt;

&lt;p&gt;The classic version of the bug is: webhook handler stores the raw payload's unit_amount_decimal in a database column, scheduled job retrieves the price via SDK and compares it to the stored value, comparison is always False, the job concludes the price changed, fires a re-pricing flow, customer gets re-charged.&lt;/p&gt;

&lt;p&gt;The fix is not "always go through the SDK" or "always parse the raw body" — both have legitimate use cases. The fix is to normalize at the edge: if you're touching this field, decide whether it lives as a string or a Decimal in your domain model, convert immediately, and never let the unconverted form leak past the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why The Migration Slipped
&lt;/h2&gt;

&lt;p&gt;Same answers as every other silent type change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SDK's own changelog is short on it.&lt;/strong&gt; The line in the release notes is "all decimal_string fields changed type from string to Decimal." Reading that fast, you assume it's a developer-experience win — typed values, better arithmetic. The downstream consequences for code that already treated those fields as strings aren't called out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Types didn't catch it where you'd expect.&lt;/strong&gt; In TypeScript/&lt;code&gt;stripe-node&lt;/code&gt;, the type does change — &lt;code&gt;string&lt;/code&gt; → &lt;code&gt;Stripe.Decimal&lt;/code&gt;. If your code depends on string operations and you ran &lt;code&gt;tsc&lt;/code&gt;, you'd see errors. But: a lot of code uses &lt;code&gt;any&lt;/code&gt; at the boundary, especially in legacy projects. A lot of code marshals through JSON and back and loses the type entirely. And a lot of code is JavaScript. None of those cases get a compile error.&lt;/p&gt;

&lt;p&gt;In Python, there are no compile-time types unless you've fully type-annotated your Stripe-touching code, which most teams haven't.&lt;/p&gt;

&lt;p&gt;In Go, the SDK uses concrete struct types and the change is visible in the type signature — Go users get the cleanest signal of any language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests against fixtures.&lt;/strong&gt; If you have unit tests that mock Stripe responses with hand-rolled JSON — strings on &lt;code&gt;unit_amount_decimal&lt;/code&gt; — those tests pass against your old SDK code (parses to string) and your new SDK code (parses string to Decimal, both still match the assertion if you also coerced), or they don't. Either way, they don't reflect what production does, because production actually goes through SDK deserialization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-level API version pinning.&lt;/strong&gt; Stripe lets you pin your account's default API version. If you pinned, the wire format won't move on you. But the SDK reshape happens regardless of the account default — it's a &lt;em&gt;client-side&lt;/em&gt; behavior. Bumping &lt;code&gt;stripe-python&lt;/code&gt; from 11.x to 12.x with the same account API version still flips your local types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependabot.&lt;/strong&gt; A Dependabot PR bumps your &lt;code&gt;stripe&lt;/code&gt; dependency, your fixture-based tests pass, you merge, deploy, and ship the type flip into production. Same Dependabot story we wrote about for &lt;a href="https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-3eo7"&gt;the Basil migration&lt;/a&gt; — different field reshape, same vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Migration That Actually Holds
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin the SDK&lt;/strong&gt; in &lt;code&gt;package.json&lt;/code&gt; / &lt;code&gt;requirements.txt&lt;/code&gt; / equivalent. Don't let the major version float. The Dahlia-aware SDK majors are Python 12.x, Node 22.x, .NET 49.x — pin explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep the codebase&lt;/strong&gt; for the affected field names: &lt;code&gt;unit_amount_decimal&lt;/code&gt;, &lt;code&gt;quantity_decimal&lt;/code&gt;, &lt;code&gt;fx_rate&lt;/code&gt;, plus any custom field on a Stripe object that ends in &lt;code&gt;_decimal&lt;/code&gt;. Note every reference. (&lt;code&gt;grep -r "unit_amount_decimal" .&lt;/code&gt; is fine to start.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify each reference&lt;/strong&gt;: is it string-style (equality, slicing, length, JSON serialize) or numeric-style (arithmetic, comparison, sorting)? String-style is what breaks; numeric-style is what gets better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert at the edge.&lt;/strong&gt; Wherever a Stripe object enters your domain (after SDK call, after webhook parse), coerce all &lt;code&gt;_decimal&lt;/code&gt; fields to a single canonical type — either &lt;code&gt;str&lt;/code&gt; if your domain is string-typed, or &lt;code&gt;Decimal&lt;/code&gt; if it's numeric-typed. Don't mix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update test fixtures.&lt;/strong&gt; If you mock Stripe with hand-rolled JSON, your fixtures need to round-trip through &lt;code&gt;stripe.util.convert_to_stripe_object&lt;/code&gt; (Python) or the equivalent SDK helper, so your tests see the post-deserialization shape, not the wire shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit JSON serialization paths.&lt;/strong&gt; Any place you &lt;code&gt;json.dumps()&lt;/code&gt; a Stripe object needs a Decimal-aware encoder (&lt;code&gt;json.dumps(obj, default=str)&lt;/code&gt; is the quick fix; a custom &lt;code&gt;JSONEncoder&lt;/code&gt; is the right fix).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage behind a flag.&lt;/strong&gt; Roll the SDK bump out incrementally — one service at a time, ideally one with low write volume first. Compare logs and outputs against the prior version for a few days before promoting.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How To See The Next One Coming
&lt;/h2&gt;

&lt;p&gt;The reshape pattern — "we kept the field name, we kept the API version contract, we just changed the runtime type the SDK hands you" — is going to keep happening. Stripe is not the only vendor doing it. Every SDK that ships a vendored numeric type, datetime type, or money type can do this in a minor SDK release without bumping the API version, and it's almost always a real improvement.&lt;/p&gt;

&lt;p&gt;The defense isn't "block SDK upgrades" — that runs out of road on security patches. The defense is to know which fields you depend on, what shape they have today, and to find out the moment that shape changes — before the deploy.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; plugs. Point it at the endpoints you depend on (Stripe price retrieve, subscription retrieve, customer retrieve) and it polls them, learns the response structure, and alerts when a field's type or shape moves — including SDK-side reshapes if you point it at SDK-deserialized output, and wire-format changes if you point it at raw HTTP. Removed fields and type flips are alerts; new optional fields are informational.&lt;/p&gt;

&lt;p&gt;You don't strictly need a tool. You need &lt;em&gt;a habit&lt;/em&gt;. Watch the runtime shape of every external response you depend on. The Dahlia decimal reshape is a textbook case of an upgrade that the type system in some languages catches, the type system in others doesn't, and the test suite in almost all of them misses entirely — because the test suite is checking the JSON shape, and the JSON shape didn't move.&lt;/p&gt;

&lt;p&gt;The runtime shape did. That's the only thing that actually matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've been bit by the Dahlia type reshape — or by any silent SDK type flip on another API — drop a note. The "the wire didn't change but the SDK did" failures are the exact ones we hear about most.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>api</category>
      <category>billing</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>GitHub's code_scanning_upload Rate Limit Field Goes Away May 19 — Your SARIF Pre-Flight Check Is About to KeyError</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 08 May 2026 04:04:05 +0000</pubDate>
      <link>https://dev.to/flarecanary/githubs-codescanningupload-rate-limit-field-goes-away-may-19-your-sarif-pre-flight-check-is-40d1</link>
      <guid>https://dev.to/flarecanary/githubs-codescanningupload-rate-limit-field-goes-away-may-19-your-sarif-pre-flight-check-is-40d1</guid>
      <description>&lt;p&gt;If your CI pipeline or security tooling makes a pre-flight call to &lt;code&gt;GET /rate_limit&lt;/code&gt; before uploading a SARIF file to GitHub, &lt;strong&gt;May 19, 2026&lt;/strong&gt; is your deadline. GitHub is &lt;a href="https://github.blog/changelog/2026-05-05-deprecation-notice-code_scanning_upload-field-will-be-removed-from-rate_limit-api-endpoint/" rel="noopener noreferrer"&gt;removing the &lt;code&gt;code_scanning_upload&lt;/code&gt; object&lt;/a&gt; from the response. Eleven days of runway from this article.&lt;/p&gt;

&lt;p&gt;The headline change is small: one key disappears from a JSON response. The interesting part is what was actually inside that key — and the silent decision your gating logic has been making since you wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exact shape change
&lt;/h2&gt;

&lt;p&gt;Today, &lt;code&gt;GET /rate_limit&lt;/code&gt; returns this under &lt;code&gt;resources&lt;/code&gt; (truncated to the relevant keys):&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;"resources"&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;"core"&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;"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;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1372700873&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;"code_scanning_upload"&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;"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;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1372700873&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;Starting May 19, 2026:&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;"resources"&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;"core"&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;"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;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"remaining"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1372700873&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;That's the whole change. The &lt;code&gt;code_scanning_upload&lt;/code&gt; key is gone. There is no replacement key, because — as we'll get to in a second — there was never a separate quota to replace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four places this breaks
&lt;/h2&gt;

&lt;p&gt;The pattern is the same &lt;code&gt;KeyError&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt;/&lt;code&gt;null pointer&lt;/code&gt; shape we covered with &lt;a href="https://dev.to/flarecanary/github-just-removed-mergecommitsha-from-pull-request-responses-your-release-bot-is-probably-156d"&gt;GitHub's &lt;code&gt;merge_commit_sha&lt;/code&gt; removal&lt;/a&gt; last month. Different surface, same failure class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pre-flight gates on SARIF uploads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common pattern in code-scanning automation:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&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;requests&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;https://api.github.com/rate_limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&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;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;csu&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;resources&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;code_scanning_upload&lt;/span&gt;&lt;span class="sh"&gt;"&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;csu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;Low budget — sleeping until &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;csu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reset&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# upload the SARIF
&lt;/span&gt;&lt;span class="nf"&gt;upload_sarif&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After May 19, the second line raises &lt;code&gt;KeyError: 'code_scanning_upload'&lt;/code&gt;. The job exits non-zero, the SARIF never uploads, the security dashboard goes stale, nobody notices because the alert was wired to the upload-success webhook, not the rate-limit-check failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. PyGithub and Octokit field accessors.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your code uses &lt;a href="https://pygithub.readthedocs.io/en/latest/github_objects/RateLimit.html" rel="noopener noreferrer"&gt;PyGithub's &lt;code&gt;RateLimit&lt;/code&gt; object&lt;/a&gt;, the attribute access is &lt;code&gt;rate_limit.code_scanning_upload&lt;/code&gt;. After May 19, that attribute will resolve to &lt;code&gt;None&lt;/code&gt; (PyGithub builds the object lazily from the JSON response — missing keys become &lt;code&gt;None&lt;/code&gt; rather than &lt;code&gt;AttributeError&lt;/code&gt;), and &lt;code&gt;rate_limit.code_scanning_upload.remaining&lt;/code&gt; will then raise &lt;code&gt;AttributeError: 'NoneType' object has no attribute 'remaining'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Octokit's TypeScript types currently mark the field as required. A typed &lt;code&gt;octokit.rest.rateLimit.get()&lt;/code&gt; consumer that destructures the field will fail at compile time the next time you bump &lt;code&gt;@octokit/openapi-types&lt;/code&gt; past the cutoff. That's actually the &lt;em&gt;good&lt;/em&gt; failure mode — the type checker catches it before deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Dashboards graphing the field separately.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you graph &lt;code&gt;code_scanning_upload.remaining&lt;/code&gt; over time on a Grafana panel, the metric flatlines on May 19 and your alert thresholds (e.g., "page if rate-limit headroom &amp;lt; 100") fire constantly until someone notices the panel is querying a key that no longer exists. Whether this is loud or silent depends on how your collector handles missing keys — Telegraf's HTTP-JSON input plugin emits a &lt;code&gt;0&lt;/code&gt; for missing fields by default, which is the worst possible outcome (silent under-reporting).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Schema-validating clients.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any client that validates against an OpenAPI schema and treats &lt;code&gt;code_scanning_upload&lt;/code&gt; as required will reject the new response as malformed until the schema is bumped. This is the most niche failure but the loudest — and it's how people who run &lt;code&gt;openapi-typescript&lt;/code&gt; against GitHub's spec catch this kind of change automatically. Most teams don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper twist: the field was always shadowing &lt;code&gt;core&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the change interesting rather than just annoying. From the GitHub Community discussion thread that prompted the deprecation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Rate Limit endpoint shows &lt;code&gt;core&lt;/code&gt; and &lt;code&gt;code_scanning_upload&lt;/code&gt; consuming the same quota during job"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does, because they &lt;em&gt;are&lt;/em&gt; the same quota. The &lt;code&gt;code_scanning_upload&lt;/code&gt; object never held its own bucket. SARIF uploads consume from the standard &lt;code&gt;core&lt;/code&gt; rate limit (5,000/hr authenticated, 15,000/hr for GitHub Apps). The duplicate object in the response was a documentation-of-intent artifact — GitHub once planned to give SARIF its own bucket, never did, and the field has been a confusing copy of &lt;code&gt;core&lt;/code&gt; ever since.&lt;/p&gt;

&lt;p&gt;Which means any of these patterns has been wrong for years:&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;# Pattern A — gating on the wrong field
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code_scanning_upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;upload_sarif&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# This was always equivalent to checking core. The if/then was redundant.
&lt;/span&gt;
&lt;span class="c1"&gt;# Pattern B — assuming separate budgets
&lt;/span&gt;&lt;span class="n"&gt;core_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;
&lt;span class="n"&gt;sarif_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code_scanning_upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;
&lt;span class="n"&gt;total_budget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;core_budget&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sarif_budget&lt;/span&gt;   &lt;span class="c1"&gt;# double-counted; the budget is one bucket
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pattern B is the silent-fail mode. Code that double-counts the budget thinks it has 10,000 requests of headroom when it actually has 5,000. On a busy day with concurrent CI shards, the second half of the budget evaporates faster than the calculation expects, and the job hits HTTP 403 with &lt;code&gt;X-RateLimit-Remaining: 0&lt;/code&gt; partway through the run — &lt;em&gt;without&lt;/em&gt; the rate-limit pre-check ever flagging it, because the pre-check was reading the (duplicate) &lt;code&gt;code_scanning_upload&lt;/code&gt; value while the upload calls debited from &lt;code&gt;core&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The May 19 removal is GitHub making this implicit truth explicit. The code that breaks loudly (KeyError) was less wrong than the code that was silently summing the same quota twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration
&lt;/h2&gt;

&lt;p&gt;For the loud-fail case (KeyError, AttributeError):&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;# Before
&lt;/span&gt;&lt;span class="n"&gt;csu&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;resources&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;code_scanning_upload&lt;/span&gt;&lt;span class="sh"&gt;"&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;csu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;sleep_until_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csu&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# After
&lt;/span&gt;&lt;span class="n"&gt;core&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;resources&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;core&lt;/span&gt;&lt;span class="sh"&gt;"&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;core&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;sleep_until_reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reset&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;For PyGithub:&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;# Before
&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_rate_limit&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;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code_scanning_upload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="c1"&gt;# After
&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_rate_limit&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;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Octokit users on TypeScript: bump &lt;code&gt;@octokit/openapi-types&lt;/code&gt; past the cutoff, run &lt;code&gt;tsc&lt;/code&gt;, and let the type errors guide the rename.&lt;/p&gt;

&lt;p&gt;For dashboards: drop the &lt;code&gt;code_scanning_upload&lt;/code&gt; panel. The &lt;code&gt;core&lt;/code&gt; panel was always showing the same numbers; you don't need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder check: is your code-scanning quota actually safe?
&lt;/h2&gt;

&lt;p&gt;Here's the question the migration itself doesn't answer: now that &lt;code&gt;code_scanning_upload&lt;/code&gt; and &lt;code&gt;core&lt;/code&gt; are the same explicit bucket, does your CI fleet actually fit inside &lt;code&gt;core&lt;/code&gt; once you stop double-counting?&lt;/p&gt;

&lt;p&gt;For a single repo doing a few SARIF uploads per push, yes. For a security team running &lt;a href="https://github.blog/changelog/type/deprecations/" rel="noopener noreferrer"&gt;GitHub Advanced Security&lt;/a&gt; across hundreds of repos with parallel CodeQL workflows on every PR, the answer might be no. The 5,000/hr limit per token is shared across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All &lt;code&gt;core&lt;/code&gt;-bucket calls (most of the REST API)&lt;/li&gt;
&lt;li&gt;All SARIF uploads (was already true; now visibly so)&lt;/li&gt;
&lt;li&gt;All Dependabot manifest fetches you do via the API&lt;/li&gt;
&lt;li&gt;Any other automation hitting the same auth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the numbers before May 19. If your aggregate &lt;code&gt;core&lt;/code&gt; consumption was sitting comfortably below 5,000 because you assumed &lt;code&gt;code_scanning_upload&lt;/code&gt; was a separate budget, you may be about to discover otherwise — except the discovery happens via 403s on uploads, not via your rate-limit pre-check.&lt;/p&gt;

&lt;p&gt;The clean fix for high-volume code scanning is using a &lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app" rel="noopener noreferrer"&gt;GitHub App with installation tokens&lt;/a&gt; (15,000/hr, 12,500/hr per installation, plus the API-only &lt;code&gt;core&lt;/code&gt; headroom). That's a token-architecture change, not a one-line field rename.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern across these GitHub deprecations
&lt;/h2&gt;

&lt;p&gt;This is the third quiet-field removal we've covered in five weeks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;April 27 — &lt;a href="https://dev.to/flarecanary/github-just-removed-mergecommitsha-from-pull-request-responses-your-release-bot-is-probably-156d"&gt;&lt;code&gt;merge_commit_sha&lt;/code&gt; removed from PR responses&lt;/a&gt; in the 2026-03-10 API version&lt;/li&gt;
&lt;li&gt;April 28 — &lt;a href="https://dev.to/flarecanary/github-just-retired-seven-org-security-fields-your-new-repo-hardening-script-is-now-a-no-op-3id7"&gt;Seven org security fields retired&lt;/a&gt; (PATCH returned 200 but applied nothing)&lt;/li&gt;
&lt;li&gt;May 19 — &lt;code&gt;code_scanning_upload&lt;/code&gt; removed from &lt;code&gt;/rate_limit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is a one-line schema change that becomes a multi-hour incident in production because the failure surfaces aren't obviously connected to the announcement. They're not the headline breaking changes from the API version page — they're the small reshapes that nobody on the team is monitoring.&lt;/p&gt;

&lt;p&gt;The unifying habit: pin the response shape of every GitHub endpoint your automation depends on, diff it on a schedule, and alert when a key disappears. That's what &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; does — it polls the endpoints you point at, learns the response shape, and flags removed fields with severity classification so the SARIF-upload script's pre-flight check finds out about the change in your alerting channel rather than at 2 AM during a security release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Action items, in order, before May 19
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Today: grep your codebases for &lt;code&gt;code_scanning_upload&lt;/code&gt;.&lt;/strong&gt; Anywhere it appears in JSON parsing, dashboard config, or schema validation is a migration target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This week: rename to &lt;code&gt;core&lt;/code&gt; in pre-flight checks&lt;/strong&gt; and verify the gate still does what you want — most "the SARIF budget is fine" gates were already lies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This week: verify &lt;code&gt;core&lt;/code&gt;-bucket headroom across your aggregate token usage.&lt;/strong&gt; If you've been flying close to the limit while assuming you had two buckets, plan token splits or move to GitHub App auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before May 19: bump &lt;code&gt;@octokit/openapi-types&lt;/code&gt; and PyGithub past the cutoff&lt;/strong&gt; in CI to surface compile/runtime errors before the production endpoint changes shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After May 19: keep the rename.&lt;/strong&gt; GitHub has been quietly trimming dead fields all spring. The next one will follow the same pattern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A 200 response on &lt;code&gt;/rate_limit&lt;/code&gt; tells you the request was accepted. It doesn't tell you the field you're reaching for is still there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your code-scanning automation has been gating on &lt;code&gt;code_scanning_upload&lt;/code&gt; and you found something interesting when you ran the numbers — drop a reply with the shape of the surprise. The shadow-quota pattern is broader than just this field.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>api</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
