<?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>Forem: FlareCanary</title>
    <description>The latest articles on Forem by FlareCanary (@flarecanary).</description>
    <link>https://forem.com/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>Forem: FlareCanary</title>
      <link>https://forem.com/flarecanary</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/flarecanary"/>
    <language>en</language>
    <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://forem.com/flarecanary/geminis-interactions-api-default-flips-may-26-your-interactionoutputs-reads-will-go-undefined-nb0</link>
      <guid>https://forem.com/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://forem.com/flarecanary/notions-api-now-caps-pagination-at-10000-results-your-fetch-all-rows-sync-is-silently-4j99</link>
      <guid>https://forem.com/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://forem.com/flarecanary/supabases-management-api-oauth-endpoint-switches-from-201-to-200-on-may-26-heres-what-silently-4cee</link>
      <guid>https://forem.com/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://forem.com/flarecanary/aem-cloud-stops-receiving-adobe-updates-june-11-if-you-use-deprecated-apis-heres-the-list-and-4c12</link>
      <guid>https://forem.com/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://forem.com/flarecanary/cloudflare-typescript-sdk-v6-made-133-methods-return-null-and-empty-bodies-return-undefined--327m</link>
      <guid>https://forem.com/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://forem.com/flarecanary/github-silently-removed-payloadcommits-from-pushevent-heres-what-broke-and-how-to-catch-the-2i33</link>
      <guid>https://forem.com/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://forem.com/flarecanary/microsoft-stripped-oldvaluenewvalue-from-dataverse-audit-events-going-to-purview-on-may-1--45h9</link>
      <guid>https://forem.com/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://forem.com/flarecanary/stripes-2026-04-22-dahlia-release-quietly-changed-unitamountdecimal-from-string-to-decimal--17m7</link>
      <guid>https://forem.com/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://forem.com/flarecanary/githubs-codescanningupload-rate-limit-field-goes-away-may-19-your-sarif-pre-flight-check-is-40d1</link>
      <guid>https://forem.com/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>
    <item>
      <title>Twilio's Transit CallerID Sunset on May 31 — Your Voice Flows Already Look Broken in Six Countries and You Probably Don't Know</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 08 May 2026 04:01:48 +0000</pubDate>
      <link>https://forem.com/flarecanary/twilios-transit-callerid-sunset-on-may-31-your-voice-flows-already-look-broken-in-six-countries-1m3o</link>
      <guid>https://forem.com/flarecanary/twilios-transit-callerid-sunset-on-may-31-your-voice-flows-already-look-broken-in-six-countries-1m3o</guid>
      <description>&lt;p&gt;If your product makes outbound voice calls through Twilio and you're presenting a CallerID that isn't a Twilio-owned or Verified CallerID number, &lt;strong&gt;May 31, 2026&lt;/strong&gt; is a date you need on the calendar. Twilio is sunsetting &lt;a href="https://www.twilio.com/en-us/changelog/action-required--transit-caller-id-sunset-migrate" rel="noopener noreferrer"&gt;Transit CallerID&lt;/a&gt;. Six countries are already blocking these calls today.&lt;/p&gt;

&lt;p&gt;The reason this one is dangerous isn't the cutoff itself — Twilio has been clear about the date for months. It's the failure mode &lt;em&gt;before&lt;/em&gt; the cutoff, in the regions that have already started enforcing. The Twilio side of the call returns 200, the &lt;code&gt;CallStatus&lt;/code&gt; webhook reports &lt;code&gt;completed&lt;/code&gt; or &lt;code&gt;in-progress&lt;/code&gt;, your aggregate latency dashboards stay green — and the call never reaches the called party because the regulated carrier drops it on the spec edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Transit CallerID is, and what's changing
&lt;/h2&gt;

&lt;p&gt;Twilio Voice lets you set a CallerID on outbound calls in three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A Twilio-owned number&lt;/strong&gt; on your account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Verified CallerID&lt;/strong&gt; — a non-Twilio number you've proven you control via the verification call flow (&lt;a href="https://www.twilio.com/docs/voice/api/outgoing-caller-ids" rel="noopener noreferrer"&gt;OutgoingCallerIds resource&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transit CallerID&lt;/strong&gt; — pass any arbitrary number through the &lt;code&gt;From&lt;/code&gt; parameter (Programmable Voice) or the SIP &lt;code&gt;From&lt;/code&gt; header (Elastic SIP Trunking), and Twilio carries it as-is to the destination carrier without proving you control it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option 3 is what's going away. Starting May 31, 2026, Twilio will reject outbound calls whose CallerID isn't a Twilio-owned number, a Verified CallerID, or a CallerID delivered through Immutable Call Forwarding (more on that below).&lt;/p&gt;

&lt;p&gt;The regulatory pressure isn't from Twilio. It's from national regulators tightening on caller ID spoofing. The countries already blocking unverified Transit CallerID at the carrier layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Australia&lt;/li&gt;
&lt;li&gt;Brazil&lt;/li&gt;
&lt;li&gt;Germany&lt;/li&gt;
&lt;li&gt;Norway&lt;/li&gt;
&lt;li&gt;Spain&lt;/li&gt;
&lt;li&gt;Sweden&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your traffic terminates in any of those countries today, the calls are already failing. The question is whether your monitoring is telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent-fail mode that gets under everyone's monitoring
&lt;/h2&gt;

&lt;p&gt;This is the part that bit teams in March and April when the early enforcement started, and it's the part that's going to bite the long tail when May 31 hits the rest of the regulated markets.&lt;/p&gt;

&lt;p&gt;Here's the call flow when a Transit CallerID call hits a country that's already enforcing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your code POSTs to &lt;code&gt;/2010-04-01/Accounts/{AccountSid}/Calls.json&lt;/code&gt; with &lt;code&gt;From=+15551234567&lt;/code&gt; (your customer's CallerID, not yours), &lt;code&gt;To=+49xxxxxxxxx&lt;/code&gt;, and a TwiML URL.&lt;/li&gt;
&lt;li&gt;Twilio's API returns &lt;strong&gt;HTTP 201&lt;/strong&gt; with a &lt;code&gt;CallSid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;statusCallback&lt;/code&gt; webhook fires &lt;code&gt;initiated&lt;/code&gt;, then &lt;code&gt;ringing&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The call traverses Twilio's network, hits the German carrier's edge (in this example), and the carrier drops it because the CallerID doesn't pass STIR/SHAKEN attestation or the local equivalent. Some regulators just block; others return a &lt;code&gt;503 Service Unavailable&lt;/code&gt; or a Q.850 cause code (16 — Normal call clearing) that &lt;em&gt;looks&lt;/em&gt; identical to a normal hangup.&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;statusCallback&lt;/code&gt; fires &lt;code&gt;completed&lt;/code&gt; with &lt;code&gt;Duration=0&lt;/code&gt; and &lt;code&gt;CallStatus=completed&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The string &lt;code&gt;completed&lt;/code&gt; is doing a lot of work there. In Twilio's status taxonomy, &lt;code&gt;completed&lt;/code&gt; means "the call was set up and torn down without an explicit failure on the signaling layer." It does not mean "the called party picked up." It does not mean "audio flowed in either direction." A call that was administratively dropped by a regulated carrier with a clean 503 looks identical to a call that connected, played a 30-second message, and hung up — except for &lt;code&gt;Duration&lt;/code&gt; and the absence of any RTP statistics.&lt;/p&gt;

&lt;p&gt;If your app tracks &lt;code&gt;CallStatus == 'completed'&lt;/code&gt; as success — and most do — your dashboard is lying to you. Every call to AU/BR/DE/ES/NO/SE has been silently failing for some teams since March.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to look for the truth
&lt;/h2&gt;

&lt;p&gt;Three signals expose the silent fail. None of them are on the default Twilio monitoring page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The Insights API call summary.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://insights.twilio.com/v1/Voice/{CallSid}/Summary
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specifically the &lt;code&gt;call_state&lt;/code&gt; and &lt;code&gt;attributes.disposition&lt;/code&gt; fields. A regulator-blocked call surfaces as &lt;code&gt;call_state=completed&lt;/code&gt;, &lt;code&gt;disposition=no-answer&lt;/code&gt; or &lt;code&gt;failed-attestation&lt;/code&gt;. The disposition lives in Insights, not in the Calls resource.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Per-event breakdown.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://insights.twilio.com/v1/Voice/{CallSid}/Events
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for an event with &lt;code&gt;name=last_sip_response_code&lt;/code&gt; greater than 400, or &lt;code&gt;name=carrier_attestation&lt;/code&gt; with a value of &lt;code&gt;C&lt;/code&gt; or null. STIR/SHAKEN attestation &lt;code&gt;C&lt;/code&gt; (gateway attestation) is what unverified Transit CallerID resolves to, and the regulated carriers in the six countries above reject it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The hangup cause code on the &lt;code&gt;completed&lt;/code&gt; callback.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even on the basic &lt;code&gt;statusCallback&lt;/code&gt;, Twilio includes the Q.850 cause when it's available: &lt;code&gt;HangupCauseCode&lt;/code&gt; and &lt;code&gt;HangupSource&lt;/code&gt;. A normal completion shows &lt;code&gt;HangupSource=caller&lt;/code&gt; or &lt;code&gt;callee&lt;/code&gt;. A regulator block frequently shows &lt;code&gt;HangupSource=carrier&lt;/code&gt; with a code in the 21–27 range (call rejected, network out of order, requested facility not subscribed). Most apps don't read those two fields.&lt;/p&gt;

&lt;p&gt;If you do nothing else before May 31, log &lt;code&gt;HangupSource&lt;/code&gt; and &lt;code&gt;HangupCauseCode&lt;/code&gt; on every status callback and graph the per-country rate of &lt;code&gt;HangupSource=carrier&lt;/code&gt;. That single dashboard surfaces the silent fail today, before the global cutoff lands.&lt;/p&gt;

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

&lt;p&gt;There are three migration targets, and they map to different use cases. Picking the wrong one means writing code that compiles but doesn't satisfy the regulator.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Static CallerID — use Verified CallerID
&lt;/h3&gt;

&lt;p&gt;If your app always presents the same CallerID on every outbound call (e.g., your business's main line), the answer is to add it as a Verified CallerID on your account.&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 https://api.twilio.com/2010-04-01/Accounts/&lt;span class="nv"&gt;$ACCOUNT_SID&lt;/span&gt;/OutgoingCallerIds.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"PhoneNumber=+15551234567"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"FriendlyName=Main Business Line"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$ACCOUNT_SID&lt;/span&gt;:&lt;span class="nv"&gt;$AUTH_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twilio places a verification call to the number. Whoever answers reads back a 6-digit code, and the number is then registered as a Verified CallerID. Once verified, calls using that number as &lt;code&gt;From&lt;/code&gt; are treated as authenticated through May 31 and beyond. Verification needs to be re-run if the underlying number changes carrier or ownership — there isn't a long-term challenge mechanism, just a one-time call.&lt;/p&gt;

&lt;p&gt;Static use case is the simplest migration. Most static-use teams will be fine if they verify their numbers before the deadline.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Dynamic CallerID where you control all the lines — port them to Twilio
&lt;/h3&gt;

&lt;p&gt;If your app rotates between a small pool of CallerIDs (regional presence numbers, vanity lines), the cleanest path is porting those numbers into your Twilio account. Twilio-owned numbers don't need verification — they're the gold-standard CallerID and carry full STIR/SHAKEN attestation &lt;code&gt;A&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the highest-effort migration if you have many numbers, because porting is a per-number process with carrier paperwork. Start the port at least 30 days before May 31 if you want to use this path.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Per-call dynamic CallerID where you don't own the numbers — Immutable Call Forwarding
&lt;/h3&gt;

&lt;p&gt;This is the one that breaks the most assumptions. If your app's whole business model is "customer A calls in, we forward to customer B's mobile, and customer B sees customer A's CallerID" — call masking, virtual receptionists, two-sided marketplace voice, customer-support outdial showing your customer's branded number — none of the above two options works. You don't own customer A's number, and you can't verify a different number per call.&lt;/p&gt;

&lt;p&gt;Twilio's answer is &lt;strong&gt;Immutable Call Forwarding&lt;/strong&gt;, marketed as PV Immutable Call Forwarding (PV-ICF) for Programmable Voice and ESIPT Immutable Call Forwarding for Elastic SIP Trunking.&lt;/p&gt;

&lt;p&gt;The mechanism is fundamentally different from Transit CallerID. Instead of a fresh outbound call where you assert a CallerID, the call leg is &lt;em&gt;forwarded&lt;/em&gt; through Twilio as a B2BUA (back-to-back user agent) bridge — the inbound leg is a real call from customer A, and the outbound leg preserves customer A's identity through the bridge under STIR/SHAKEN's call diversion mechanism.&lt;/p&gt;

&lt;p&gt;For Programmable Voice TwiML, this is a &lt;code&gt;&amp;lt;Dial&amp;gt;&lt;/code&gt; invocation against a leg you've received, not a fresh outbound &lt;code&gt;&amp;lt;Dial&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Response&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Dial&lt;/span&gt; &lt;span class="na"&gt;callerId=&lt;/span&gt;&lt;span class="s"&gt;"{{the inbound caller's number, immutably forwarded}}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Number&amp;gt;&lt;/span&gt;+49xxxxxxxxx&lt;span class="nt"&gt;&amp;lt;/Number&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/Dial&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Response&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraint: the call must originate from an inbound leg you received. You can't synthesize an outbound call with PV-ICF — you need a real inbound to forward. For most call-masking products this is exactly what's happening today; the change is in how Twilio attests it on the way out.&lt;/p&gt;

&lt;p&gt;For Elastic SIP Trunking the equivalent is configured at the trunk level under Call Transfer settings, with the "Caller ID for Transfer Target" knob set to &lt;code&gt;Transferor&lt;/code&gt; (preserving the original caller's identity) instead of the default &lt;code&gt;Transferee&lt;/code&gt;. SIP REFER carries the diversion header that the destination carrier honors as a forwarded leg, not a Transit CallerID.&lt;/p&gt;

&lt;p&gt;If your product is in the marketplace/masking/virtual-receptionist category and you don't have ICF wired up before May 31, the migration is not a small change — you need to refactor your call paths so every dynamic CallerID call originates from an inbound leg, and your TwiML needs to flip from outbound &lt;code&gt;&amp;lt;Dial&amp;gt;&lt;/code&gt; to inbound-then-&lt;code&gt;&amp;lt;Dial&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your tests probably don't catch it
&lt;/h2&gt;

&lt;p&gt;This one shares the shape of the prior Twilio regional-domains intercept, but the silent-fail surface is wider. Five reasons it slips through:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sandboxes don't enforce.&lt;/strong&gt; Twilio's test credentials don't run STIR/SHAKEN attestation. Calls placed in test mode complete cleanly regardless of CallerID provenance. The validation only kicks in when the call hits a real PSTN destination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests don't dial.&lt;/strong&gt; They mock the SDK, assert that &lt;code&gt;client.calls.create&lt;/code&gt; was called with the right &lt;code&gt;From&lt;/code&gt;, and move on. The string &lt;code&gt;+15551234567&lt;/code&gt; is just as valid in a mock as a verified number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests don't terminate internationally.&lt;/strong&gt; Most teams' integration tests dial the team's own numbers — almost always US/CA. The six countries already enforcing are not on the default test list. Integration tests pass; production calls to AU/BR/DE/ES/NO/SE quietly drop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;CallStatus&lt;/code&gt; is a misleading signal.&lt;/strong&gt; As covered above, &lt;code&gt;completed&lt;/code&gt; doesn't mean delivered. Apps that key on &lt;code&gt;CallStatus&lt;/code&gt; for success monitoring are systematically reporting blocked calls as successes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Twilio Console call log shows them as completed too.&lt;/strong&gt; This one trips up support teams. The default call log view shows duration and status; a regulator-blocked call has duration 0 and status completed. It looks identical to a hung-up-on-ringback call, which happens for legitimate reasons all the time. Until you click into the call detail page and read the Insights events, there's no flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do, in order, before May 31
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Today: log &lt;code&gt;HangupSource&lt;/code&gt; and &lt;code&gt;HangupCauseCode&lt;/code&gt;&lt;/strong&gt; on every status callback. Graph the rate of &lt;code&gt;HangupSource=carrier&lt;/code&gt; per destination country code. If the AU/BR/DE/ES/NO/SE rate jumps relative to baseline, you have Transit CallerID exposure live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This week: audit your &lt;code&gt;From&lt;/code&gt; numbers.&lt;/strong&gt; Pull the last 30 days of &lt;code&gt;Calls&lt;/code&gt; and group by the &lt;code&gt;From&lt;/code&gt; field. Anything that isn't a Twilio-owned number (&lt;code&gt;incoming_phone_numbers&lt;/code&gt; resource) or a verified &lt;code&gt;OutgoingCallerIds&lt;/code&gt; entry is exposed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Next week: classify by use case.&lt;/strong&gt; Static lines → Verified CallerID. Owned but external → port to Twilio. Customer-presented per-call → ICF refactor.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Before mid-May: complete the Verified CallerID flow&lt;/strong&gt; for static numbers. The verification call requires someone to answer and read a code, so this is a coordination problem, not a technical one. Don't leave it for the last week.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Before May 31: migrate the ICF path.&lt;/strong&gt; This is the largest change for marketplace and call-masking products. Don't skip it because the cutoff date moves through your release schedule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;After May 31: keep the per-country &lt;code&gt;HangupSource=carrier&lt;/code&gt; dashboard.&lt;/strong&gt; The list of regulated countries will grow. Saudi Arabia, France, and Japan are all on the watch list of the same regulator coalition that drove the AU/BR/DE/ES/NO/SE rollouts. Today's six are not the last six.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How drift monitoring catches this class of change
&lt;/h2&gt;

&lt;p&gt;The pattern in this Twilio change is identical to the one in the regional-domains deprecation we covered last month: a long pre-announced cutoff, an authoritative changelog post, and a per-region silent enforcement window before the global flag day. It also matches the GitHub merge_commit_sha removal, the OpenAI Responses input_text deprecation, and the Stripe Basil current_period_end move — all changes where the API surface kept returning 200 long after the underlying contract had drifted out from under the caller.&lt;/p&gt;

&lt;p&gt;The fix at the architecture level is to stop treating "200 OK" as a success signal at the boundary. The API tells you the request was accepted; it doesn't tell you the contract still holds.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; was built to close. Point it at the Twilio Calls and Insights endpoints you depend on, and it polls on a schedule, learns the expected response shape, and flags when a field's nullability changes, when a new attestation field appears, when an enum tightens. Severity-classified so noise stays low.&lt;/p&gt;

&lt;p&gt;You don't need a dedicated tool. You can cron a script that hits the relevant endpoints, hashes the field set, and diffs. The point is that &lt;em&gt;some&lt;/em&gt; layer needs to be watching response shape &lt;em&gt;and&lt;/em&gt; per-country dispositions, because the regulator-driven changes don't bump API versions when they tighten — they just tighten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder pattern
&lt;/h2&gt;

&lt;p&gt;This is the third Twilio incident we've covered in the series. Regional domains in April. A2P 10DLC required fields in June. Transit CallerID in May. Three different teams inside Twilio, three different changelog posts, three different failure surfaces — and the same silent-success pattern across all of them.&lt;/p&gt;

&lt;p&gt;Most teams have a list of which Twilio products they use. Almost none of them have a list of which Twilio &lt;em&gt;contracts&lt;/em&gt; they assume — which fields exist, which CallerIDs work in which countries, which API versions their SDK pin resolves to. That gap, between "what we use" and "what we'd find out about a schema or policy change in advance of the cutoff," is the entire reason silent-fail incidents land in production instead of in CI.&lt;/p&gt;

&lt;p&gt;A 200 response on a call placement tells you the request was accepted. It doesn't tell you the call rang in Berlin.&lt;/p&gt;

&lt;p&gt;If you'd been diffing Twilio's STIR/SHAKEN attestation field on calls to your top destination countries since the AU/BR rollout, you'd have caught the silent failure six weeks before the global cutoff — long enough to verify your numbers, port what needs porting, and refactor your ICF paths without scrambling.&lt;/p&gt;

&lt;p&gt;That's the habit, and it generalizes to every voice provider, every regulator coalition, every API you don't control.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your product runs voice through Twilio and you've already seen the regional silent-fail pattern, drop a reply with the country and the timeline. We're collecting these and the regulator coalition behind the rollouts looks bigger than the published list.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>twilio</category>
      <category>voice</category>
      <category>sip</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Twilio A2P 10DLC Campaign Registration Will 400 on June 30 — Two New Required Fields Most SaaS Apps Are Missing</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Thu, 07 May 2026 04:02:56 +0000</pubDate>
      <link>https://forem.com/flarecanary/twilio-a2p-10dlc-campaign-registration-will-400-on-june-30-two-new-required-fields-most-saas-apps-fm</link>
      <guid>https://forem.com/flarecanary/twilio-a2p-10dlc-campaign-registration-will-400-on-june-30-two-new-required-fields-most-saas-apps-fm</guid>
      <description>&lt;p&gt;If your product sends SMS through Twilio in the United States, there's a Messaging Service campaign registered against your A2P 10DLC brand. On &lt;strong&gt;June 30, 2026&lt;/strong&gt;, two new fields become &lt;strong&gt;required&lt;/strong&gt; on every campaign registration: &lt;code&gt;PrivacyPolicyUrl&lt;/code&gt; and &lt;code&gt;TermsAndConditionsUrl&lt;/code&gt;. Submissions without them will return a hard 400 from Twilio.&lt;/p&gt;

&lt;p&gt;This is the kind of change that doesn't break anything — until it does. Existing campaigns keep delivering messages. New campaigns and any update to a campaign hit the new validator the moment you touch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's changing
&lt;/h2&gt;

&lt;p&gt;Today, Twilio's A2P 10DLC campaign registration accepts campaigns without a privacy policy URL or terms-and-conditions URL on the campaign object itself. Brand-level URLs cover the carrier compliance story, and the campaign-level fields are technically optional.&lt;/p&gt;

&lt;p&gt;After June 30, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PrivacyPolicyUrl&lt;/code&gt; is required on every new campaign registration&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TermsAndConditionsUrl&lt;/code&gt; is required on every new campaign registration&lt;/li&gt;
&lt;li&gt;Both URLs must resolve to publicly reachable HTTPS pages — Twilio fetches them as part of the registration check&lt;/li&gt;
&lt;li&gt;The privacy policy must explicitly mention SMS data collection and that opt-in data is not shared with third parties&lt;/li&gt;
&lt;li&gt;Terms must include the program's purpose, opt-out instructions ("STOP"), and the help keyword behavior ("HELP")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Submitting a campaign without either field after the cutoff returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 400 Bad Request
{
  "code": 21610,
  "message": "PrivacyPolicyUrl is required for campaign registration",
  "more_info": "https://www.twilio.com/docs/api/errors/21610",
  "status": 400
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape for &lt;code&gt;TermsAndConditionsUrl&lt;/code&gt;. Same outcome: registration fails, the Messaging Service stays in a &lt;code&gt;pending&lt;/code&gt; state, and SMS through that service won't deliver until you complete the registration with valid URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays working — and what doesn't
&lt;/h2&gt;

&lt;p&gt;The trap here is that this isn't a flag-day cutover for &lt;em&gt;delivery&lt;/em&gt;. Existing campaigns that were registered before June 30 continue to send messages.&lt;/p&gt;

&lt;p&gt;What breaks is anything that triggers a re-registration or update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creating a new Messaging Service&lt;/strong&gt; for a new product, region, or environment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding a campaign to an existing brand&lt;/strong&gt; — even programmatically via the Trust Hub API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modifying campaign fields&lt;/strong&gt; — message samples, use case, sample message frequency. Any PATCH against &lt;code&gt;/v1/Campaigns/{sid}&lt;/code&gt; runs through the new validator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reapproving a flagged campaign&lt;/strong&gt; — if a carrier flags a campaign for review (volume spike, content concern), the resubmission goes through the new schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrating between brands&lt;/strong&gt; — moving a campaign to a different A2P brand requires a fresh registration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is: &lt;strong&gt;anything you do to A2P 10DLC after June 30 has to satisfy the new fields, even if the underlying campaign was approved years ago.&lt;/strong&gt; A team that hasn't touched A2P registration since 2024 might not even realize their internal tools assume the old schema.&lt;/p&gt;

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

&lt;p&gt;Update your campaign registration code to always include both fields. The current Twilio Node.js SDK shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; await client.messaging.v1
   .services(messagingServiceSid)
   .usAppToPerson
   .create({
     brandRegistrationSid: 'BNxxxxxxxxxxxxxxxx',
     description: 'Order status notifications and shipping updates',
     messageSamples: ['Your order #1234 has shipped...'],
     usAppToPersonUsecase: 'MIXED',
     hasEmbeddedLinks: true,
     hasEmbeddedPhone: false,
&lt;span class="gi"&gt;+    privacyPolicyUrl: 'https://example.com/privacy',
+    termsAndConditionsUrl: 'https://example.com/terms',
&lt;/span&gt;   });
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for the REST API directly — &lt;code&gt;PrivacyPolicyUrl&lt;/code&gt; and &lt;code&gt;TermsAndConditionsUrl&lt;/code&gt; are top-level form fields on &lt;code&gt;POST /v1/Services/{sid}/Compliance/Usa2p&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things worth checking on the URLs themselves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The URLs must be publicly reachable.&lt;/strong&gt; Twilio fetches them server-side during registration. If they sit behind your auth layer, behind a staging-only domain, or only render with JavaScript, registration fails with a separate validation error that's worded like a generic "URL not accessible" message.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The privacy policy must mention SMS specifically.&lt;/strong&gt; A generic site-wide privacy policy that doesn't reference SMS data collection or carrier opt-in handling can pass URL fetch but get rejected on content review by the carrier (T-Mobile is the strict one). The rejection arrives later, asynchronously, with a vague "campaign rejected by carrier" reason.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why nobody's tests are going to catch it
&lt;/h2&gt;

&lt;p&gt;This one fits the same pattern we keep seeing across our incident-intercept series, but with its own twist:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests don't catch it&lt;/strong&gt; because the fields aren't required &lt;em&gt;today&lt;/em&gt;. Tests pass against a sandbox that hasn't shipped the new validator yet. Twilio rolls staging changes ahead of production with usually a week or two of overlap, but the cutoff date itself isn't behind a feature flag — when it lands, it lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests don't catch it&lt;/strong&gt; because most teams test message &lt;em&gt;sending&lt;/em&gt;, not campaign registration. Registration happens once per Messaging Service, manually, often by a non-developer through the Twilio Console. There's no scheduled test that says "create a campaign and verify it accepts our payload."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SDK doesn't catch it&lt;/strong&gt; because the Twilio SDK doesn't enforce required fields client-side — it sends what you give it and lets the server validate. Adding a &lt;code&gt;required: true&lt;/code&gt; to the Node.js typedef would help, but Twilio's typedefs lag the policy changes, and policy changes don't bump SDK majors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring doesn't catch it&lt;/strong&gt; because successful production message delivery doesn't exercise the registration path. Your Datadog dashboard will be green right up to the moment someone tries to add a new use case and the registration 400s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provisioning runbooks are where this hits hardest.&lt;/strong&gt; Most teams have a runbook — code or wiki — for spinning up a new Twilio environment. That runbook captures the schema as it was the day it was written. If it was written in 2024 or 2025, it doesn't include these fields. The first person to use the runbook after June 30 will spend a confused hour figuring out why their carefully copy-pasted command 400s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The carrier-rejection trap
&lt;/h2&gt;

&lt;p&gt;Even when registration passes the Twilio API check, the campaign goes to the carriers (T-Mobile, AT&amp;amp;T, Verizon) for content review. T-Mobile in particular has tightened content review through 2025–2026, and they'll reject campaigns whose privacy policy doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;explicitly state that SMS opt-in data is not shared or sold to third parties for marketing&lt;/li&gt;
&lt;li&gt;include the program name and a description of the type of messages&lt;/li&gt;
&lt;li&gt;specify message frequency ("up to N messages per month") or that frequency varies&lt;/li&gt;
&lt;li&gt;list standard "Message and data rates may apply" language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twilio's error in this case isn't a 400 on registration — registration succeeds. The campaign sits in a &lt;code&gt;failed&lt;/code&gt; state hours later, and the API response surfaces a &lt;code&gt;failure_reason&lt;/code&gt; that's frequently just "rejected by carrier" without specifics.&lt;/p&gt;

&lt;p&gt;If your privacy policy is older than 2024 or hasn't been updated specifically for SMS, this is the more likely failure mode after June 30. Update both URLs &lt;em&gt;and&lt;/em&gt; their content before the deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually catch this
&lt;/h2&gt;

&lt;p&gt;For this specific change, three checks are worth setting up in May:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Audit your existing Messaging Services.&lt;/strong&gt; Pull every Messaging Service and Campaign in your Twilio account and check whether &lt;code&gt;privacy_policy_url&lt;/code&gt; and &lt;code&gt;terms_and_conditions_url&lt;/code&gt; are set. The Twilio CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;twilio api:messaging:v1:services:list
twilio api:messaging:v1:services:compliance:usa2p:list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-sid&lt;/span&gt; MGxxxxxxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compliance API surfaces the campaign object with both URL fields. Anything blank is a campaign that will fail re-registration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pre-stage the URLs on every brand.&lt;/strong&gt; Even if you're not registering new campaigns, having the URLs ready and resolving means you can update existing campaigns in place before June 30 without scrambling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Diff Twilio's response shape over time.&lt;/strong&gt; Twilio's API responses include the campaign schema. The fields go from optional to required without an API version bump — but the OpenAPI spec, the SDK typedefs, and the response payloads all evolve. Watching for those diffs is exactly the kind of thing schema-drift monitoring is built for.&lt;/p&gt;

&lt;p&gt;That third one is what we've been building at &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt;. Point it at the Twilio Trust Hub API endpoints you depend on, and it polls on a schedule, learns the expected structure, and flags when a field's nullability changes, when a new required field appears, or when an enum tightens. Severity-classified so noise stays low.&lt;/p&gt;

&lt;p&gt;You don't need a dedicated tool. You can cron a script that hits the relevant Twilio endpoints, hashes the field set, and diffs. The point is that &lt;em&gt;some&lt;/em&gt; layer has to be watching response shape, because the carrier compliance world doesn't bump versions when it tightens — it just tightens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder question
&lt;/h2&gt;

&lt;p&gt;This is the second Twilio incident we've covered in the series. The first was the regional domain deprecation (&lt;code&gt;api.de1.twilio.com&lt;/code&gt; going dark on April 28, 2026). Both have the same shape: a long pre-announced cutoff, a real public docs page, and a failure mode that doesn't show up in your day-to-day delivery metrics until a specific provisioning or re-registration path runs.&lt;/p&gt;

&lt;p&gt;Most teams know what their Twilio bill looks like. Almost none of them have a list of every Messaging Service in every Twilio account, with a flag for which ones have the new compliance fields populated. That gap — between "what we use" and "what we'd find out about a schema change in advance of the cutoff" — is the same gap we see for OpenAI, GitHub, Stripe, Shopify.&lt;/p&gt;

&lt;p&gt;A 200 response on a message send tells you SMS is delivering. It doesn't tell you the next campaign you try to register won't 400.&lt;/p&gt;

&lt;p&gt;If you'd been diffing the Twilio campaign registration schema against a baseline since the announcement, you'd have seen the field requirement land in staging weeks before the production cutoff — long enough to update every runbook and every IaC file before the deadline ever became a problem.&lt;/p&gt;

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




&lt;p&gt;&lt;em&gt;If you've been hit by an A2P 10DLC change that broke a registration path you hadn't touched in months — drop a reply. We've been collecting these and the pattern across providers is remarkably consistent.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>twilio</category>
      <category>sms</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>GitHub App installation tokens are getting longer in May 2026 — your VARCHAR(40) column is about to silently truncate them</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 06 May 2026 04:03:54 +0000</pubDate>
      <link>https://forem.com/flarecanary/github-app-installation-tokens-are-getting-longer-in-may-2026-your-varchar40-column-is-about-to-2886</link>
      <guid>https://forem.com/flarecanary/github-app-installation-tokens-are-getting-longer-in-may-2026-your-varchar40-column-is-about-to-2886</guid>
      <description>&lt;p&gt;GitHub announced on April 24, 2026 that &lt;strong&gt;installation access tokens for GitHub Apps are changing format&lt;/strong&gt;. Starting with a brownout mid-May 2026 and full cutover by late June 2026, tokens will grow from the current 40 characters (&lt;code&gt;ghs_&lt;/code&gt; + 36 chars) to up to roughly 520 characters. The prefix stays &lt;code&gt;ghs_&lt;/code&gt;. The character set stays the same. Only the length changes — and only upward, and only sometimes.&lt;/p&gt;

&lt;p&gt;That last part is the trap. During and after the rollout, &lt;em&gt;some&lt;/em&gt; tokens will still be 40 chars (issued before the change, cached, returned by older Enterprise Server versions) and some will be 200, 380, 520. The same App, same installation, same call, on different days returns different lengths. There's no transition flag. There's no version header. The token still parses as bytes. It just doesn't fit anywhere you assumed it would.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four shapes of the failure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# All four of these silently corrupt or reject valid tokens after May 2026.
&lt;/span&gt;
&lt;span class="c1"&gt;# 1. Database column too narrow.
&lt;/span&gt;&lt;span class="n"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;TABLE&lt;/span&gt; &lt;span class="nf"&gt;app_tokens &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;installation_id&lt;/span&gt; &lt;span class="n"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="nc"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;silently&lt;/span&gt; &lt;span class="n"&gt;truncates&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt; &lt;span class="n"&gt;chars&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;insert&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Regex validator pinned to old length.
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^ghs_[A-Za-z0-9]{36}$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# rejects new tokens
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;TOKEN_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Length assertion in middleware.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;40&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;expected 40-char token, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&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="c1"&gt;# 4. Fixed-size buffer in C/Go/Rust FFI.
&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="n"&gt;token_buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;  &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;overflows&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="n"&gt;memcpy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;d, or strncpy truncates
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three return 401 from GitHub with no helpful message — your code stored or transmitted a truncated/rejected token, GitHub rejected the truncated value, and the error trail goes cold one frame above the auth call. The fourth gets you a memory bug.&lt;/p&gt;

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

&lt;p&gt;Three reasons this rolls out as silent corruption rather than red-letter outage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Type system unchanged.&lt;/strong&gt; The token is still a string. Static analysis, schema validation, OpenAPI spec, type guards — all still pass. No compile error, no schema drift alarm. Octokit, PyGithub, go-github all return &lt;code&gt;string&lt;/code&gt; from their token endpoints today and tomorrow.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Runtime unchanged at the issuance call.&lt;/strong&gt; &lt;code&gt;POST /app/installations/{installation_id}/access_tokens&lt;/code&gt; still returns 201 with a &lt;code&gt;token&lt;/code&gt; field. Your code reads the field, uses it, gets a 401 on the &lt;em&gt;next&lt;/em&gt; call — far from the issuance frame. A naive retry-on-401 hides it briefly, until the new token of the new size also fails to fit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Brownout is intermittent.&lt;/strong&gt; Per the GitHub announcement, the change rolls out as a brownout starting mid-May before full cutover late June. During the brownout window, the same token endpoint can return a 40-char token at 9:00am and a 380-char token at 9:30am. Tests written against a recorded fixture pass. CI passes. Production hits the brownout at unpredictable times.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to grep
&lt;/h2&gt;

&lt;p&gt;Search every repo that touches a GitHub App for the four patterns:&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;# 1. DB column types&lt;/span&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;"token.*VARCHAR&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;([0-9]+)&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.sql'&lt;/span&gt; &lt;span class="s1"&gt;'*.ts'&lt;/span&gt; &lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="s1"&gt;'*.go'&lt;/span&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;"varchar&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;|VARCHAR&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;|String&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;

&lt;span class="c"&gt;# 2. Regex validators&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'ghs_\[A-Za-z0-9\]\{[0-9]+\}|ghs_\\\w\{[0-9]+\}'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'ghs_'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="s1"&gt;'*.ts'&lt;/span&gt; &lt;span class="s1"&gt;'*.go'&lt;/span&gt; &lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="s1"&gt;'*.java'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'match|regex|RE|Pattern'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Length assertions&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'len\(token\)\s*==\s*40|token\.length\s*==\s*40'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'fixed.*40|token\[0:40\]|substring\(0, 40\)'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;

&lt;span class="c"&gt;# 4. Fixed-size buffers&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'char token\[[0-9]+\]|\[40\]byte|token\[64\]'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.c'&lt;/span&gt; &lt;span class="s1"&gt;'*.cpp'&lt;/span&gt; &lt;span class="s1"&gt;'*.go'&lt;/span&gt; &lt;span class="s1"&gt;'*.rs'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common hiding spots beyond what grep catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Caching layers.&lt;/strong&gt; Redis with &lt;code&gt;MAXLEN&lt;/code&gt;, Memcached with item-size limits (default 1MB is fine, but custom-tuned smaller installs are not).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit logs.&lt;/strong&gt; A token-redaction filter that masks the &lt;em&gt;first 36 chars after &lt;code&gt;ghs_&lt;/code&gt;&lt;/em&gt; still leaks the last hundred characters of the new tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook signature verification.&lt;/strong&gt; Code that uses an installation token in a downstream service's HMAC by hashing a fixed prefix length.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variable storage.&lt;/strong&gt; Some platforms truncate env vars over a length. Heroku, Vercel, Cloud Run all have limits per-platform; check your edition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT claims.&lt;/strong&gt; Apps that embed an installation token in a custom claim and the verifier asserts a fixed claim size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test fixtures.&lt;/strong&gt; Recorded VCR cassettes, json fixtures, mock objects with hardcoded 40-char strings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most-bitten codebase is the one that wrote a Probot/Octokit-style &lt;code&gt;validateToken&lt;/code&gt; helper years ago, copied a regex from a Stack Overflow answer, and forgot it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why VARCHAR(40) bites the hardest
&lt;/h2&gt;

&lt;p&gt;Postgres and MySQL with &lt;code&gt;strict mode&lt;/code&gt; raise an error when you try to insert a string longer than the column declared length — that's the &lt;em&gt;good&lt;/em&gt; outcome, because the insert fails loudly and the call site gets the exception.&lt;/p&gt;

&lt;p&gt;The bad outcome is what most production systems actually do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MySQL with non-strict mode&lt;/strong&gt; (the historic default before 5.7, and still common in older Docker images): silently truncates and emits a warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL Server with &lt;code&gt;ANSI_WARNINGS OFF&lt;/code&gt;&lt;/strong&gt;: silently truncates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some ORMs with default-string-conversion&lt;/strong&gt;: do the truncation client-side before send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token gets stored at 40 chars. Reads return 40 chars. The auth call sends 40 chars to GitHub. GitHub rejects with 401. Your stack trace shows a 401 from &lt;code&gt;POST /repos/.../check-runs&lt;/code&gt; — five layers away from the truncating column.&lt;/p&gt;

&lt;p&gt;The fix is a column type change: &lt;code&gt;ALTER TABLE app_tokens ALTER COLUMN token TYPE TEXT&lt;/code&gt;. There's no business reason to constrain token length at the storage layer; tokens are opaque to your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that doesn't migrate cleanly
&lt;/h2&gt;

&lt;p&gt;The obvious fix — widen all the columns, drop all the regexes, remove all the assertions — is correct end-state but introduces a window in the middle where the new tokens land in code paths that &lt;em&gt;also&lt;/em&gt; still have stale references to the old format.&lt;/p&gt;

&lt;p&gt;Three places this bites:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reverse proxies and middleware.&lt;/strong&gt; A Cloudflare Worker or Express middleware that strips/validates tokens. If you update the App but not the Worker, the Worker rejects valid tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-service token forwarding.&lt;/strong&gt; Service A fetches a token, hands it to Service B, B logs and validates the format. Updating only A means B starts dropping tokens it gets from A.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD secret scanning.&lt;/strong&gt; Internal secret scanners that pattern-match on &lt;code&gt;ghs_[A-Za-z0-9]{36}&lt;/code&gt; will &lt;em&gt;stop catching&lt;/em&gt; leaked tokens after the format change, because the regex no longer matches the new format. Update the scanners or you have a leak detection regression at the same time as the migration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order to roll: scanner regexes first (they need the broadest pattern, &lt;code&gt;ghs_[A-Za-z0-9]+&lt;/code&gt;), then storage layer, then validators and assertions. The App itself needs no change — Octokit and friends will get the new tokens automatically once GitHub starts issuing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "broadest pattern" means for the regex
&lt;/h2&gt;

&lt;p&gt;Don't anchor on the new max length (~520) either; that's a current ceiling that GitHub may move again later. Anchor only on the prefix and character set:&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;# Wrong — pins to current new ceiling
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^ghs_[A-Za-z0-9]{36,520}$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Right — accepts anything matching prefix and charset
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^ghs_[A-Za-z0-9]+$&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;If you actually need a length constraint for a defensive reason (e.g., a sanity check before storing), set a generous upper bound — 4096 is a reasonable belt-and-suspenders ceiling that won't bind on a future format change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not changing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefix.&lt;/strong&gt; Still &lt;code&gt;ghs_&lt;/code&gt;. (Personal access tokens use &lt;code&gt;ghp_&lt;/code&gt;, OAuth tokens &lt;code&gt;gho_&lt;/code&gt;, app-to-server &lt;code&gt;ghu_&lt;/code&gt;. None of these are announced as changing in this rollout, but the same lessons apply if they do later — strip your fixed lengths now.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issuance API.&lt;/strong&gt; &lt;code&gt;POST /app/installations/{installation_id}/access_tokens&lt;/code&gt; returns the same JSON shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation API.&lt;/strong&gt; &lt;code&gt;DELETE /installation/token&lt;/code&gt; still works the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token TTL.&lt;/strong&gt; Still 1 hour from issuance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions model.&lt;/strong&gt; Unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Minimum-viable fix
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git grep&lt;/code&gt; the four patterns above (column type, regex, length assertion, fixed buffer). Inventory every match.&lt;/li&gt;
&lt;li&gt;Update DB columns to &lt;code&gt;TEXT&lt;/code&gt; (or equivalent unbounded string type). For MySQL specifically, drop indexes on the token column before changing type if you have any — the index becomes invalid after the change.&lt;/li&gt;
&lt;li&gt;Replace fixed-length regexes with prefix-only validation (&lt;code&gt;^ghs_[A-Za-z0-9]+$&lt;/code&gt;) or remove the validation entirely (tokens are opaque, your code shouldn't be parsing them).&lt;/li&gt;
&lt;li&gt;Remove length assertions in middleware and FFI boundaries. Resize fixed-size buffers to a generous ceiling (1024 or 4096).&lt;/li&gt;
&lt;li&gt;Update internal secret scanners &lt;em&gt;first&lt;/em&gt; — before any token-handling change — so leak detection doesn't regress mid-migration.&lt;/li&gt;
&lt;li&gt;Add a real integration test against &lt;code&gt;POST /app/installations/{id}/access_tokens&lt;/code&gt; and assert that the returned token round-trips through your storage layer without modification (use a length-comparison check, not a string-equality check, to keep the test stable across token issuances).&lt;/li&gt;
&lt;li&gt;If you operate GitHub Enterprise Server, plan for the format change in your next GHES upgrade — the brownout schedule for GHES typically lags the GitHub.com schedule by one or two minor versions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The pattern this fits
&lt;/h2&gt;

&lt;p&gt;Token format changes are the canonical silent-fail. The type system sees a string. The schema sees a string. The contract test sees a string. The thing that breaks is an &lt;em&gt;assumption about the string's shape&lt;/em&gt; — and assumptions don't show up in any contract.&lt;/p&gt;

&lt;p&gt;GitHub has done this before (the &lt;a href="https://github.blog/security/application-security/behind-githubs-new-authentication-token-formats/" rel="noopener noreferrer"&gt;token format change in 2021&lt;/a&gt; introduced the &lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;ghs_&lt;/code&gt;, etc. prefixes), and the same shape of breakage rolled out then: VARCHAR columns silently truncating, regex validators silently rejecting, fixed-buffer assertions overflowing. The fix five years ago was the same fix today: don't constrain the storage size, don't validate the format past the prefix, don't bake assumptions about token shape into anything except the issuance call itself.&lt;/p&gt;

&lt;p&gt;If you're treating an opaque token as anything more structured than "an opaque blob from GitHub that is at most a few KB," you're carrying a latent bug that will trip on the next format change after this one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; monitors REST APIs and MCP servers for schema drift. Free tier covers 5 endpoints with daily checks.&lt;/em&gt;&lt;/p&gt;

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