<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FlareCanary</title>
    <description>The latest articles on DEV Community by FlareCanary (@flarecanary).</description>
    <link>https://dev.to/flarecanary</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3834499%2F8c191c74-2040-4cd1-beaa-4ca99b664ca9.png</url>
      <title>DEV Community: FlareCanary</title>
      <link>https://dev.to/flarecanary</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/flarecanary"/>
    <language>en</language>
    <item>
      <title>Shopify Scripts stop executing June 30 — and the failure is silent: checkout completes at full price, no error</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 13 Jun 2026 05:00:46 +0000</pubDate>
      <link>https://dev.to/flarecanary/shopify-scripts-stop-executing-june-30-and-the-failure-is-silent-checkout-completes-at-full-pmb</link>
      <guid>https://dev.to/flarecanary/shopify-scripts-stop-executing-june-30-and-the-failure-is-silent-checkout-completes-at-full-pmb</guid>
      <description>&lt;p&gt;On &lt;strong&gt;June 30, 2026&lt;/strong&gt;, every Shopify Script stops executing. This is the final deadline — it has already been pushed twice (August 2024 → August 2025 → June 2026), and Shopify has stated this one is firm. Editing and publishing new Scripts was already turned off on April 15, 2026.&lt;/p&gt;

&lt;p&gt;Most "deadline" posts about this frame it as a migration-project problem: audit your Scripts, rebuild them as Functions, ship before the date. That's true. But it buries the part that actually bites.&lt;/p&gt;

&lt;p&gt;Here is the part that makes this dangerous.&lt;/p&gt;

&lt;p&gt;When a model gets retired — Imagen, an OpenAI snapshot, a Grok slug — the failure is &lt;strong&gt;loud&lt;/strong&gt;. The call returns an error, your pipeline throws, someone gets paged. Loud failures get fixed.&lt;/p&gt;

&lt;p&gt;A Shopify Script ceasing to execute is &lt;strong&gt;not loud&lt;/strong&gt;. There is no Script to call and fail. The checkout simply runs without it. The customer reaches the thank-you page. Shopify returns a completed order. Your logs show a successful checkout. Everything is &lt;code&gt;200 OK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The only thing missing is the logic the Script was doing — and nothing in the system says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The cutoff itself fails silent — checkout just stops applying your rules
&lt;/h2&gt;

&lt;p&gt;Think about what Shopify Scripts actually do. They are Ruby scripts that run inside checkout to modify three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line item discounts&lt;/strong&gt; — "20% off for wholesale-tagged customers," "buy 3 get 1 free," tiered volume pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping rates&lt;/strong&gt; — hide express shipping for PO boxes, rename rates, discount freight for VIPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment methods&lt;/strong&gt; — hide credit card for high-risk carts, hide cash-on-delivery above a cart threshold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On July 1, none of that runs. And checkout does not error — it has nothing to error &lt;em&gt;about&lt;/em&gt;. It just renders the cart without the Script's modifications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The wholesale customer who should see 20% off &lt;strong&gt;pays full price.&lt;/strong&gt; No error. Order completes.&lt;/li&gt;
&lt;li&gt;The express shipping you hid for PO boxes &lt;strong&gt;shows up again&lt;/strong&gt;, gets selected, and now you owe a carrier for a route you can't service.&lt;/li&gt;
&lt;li&gt;The payment method you hid on high-risk carts &lt;strong&gt;comes back&lt;/strong&gt;, and the fraud you were screening out walks straight through.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these is a &lt;code&gt;200&lt;/code&gt;, a completed order, a happy-looking checkout funnel. You will not find this in an error dashboard, because there is no error. You find it in a margin report three weeks later, or in a customer support ticket, or in a chargeback.&lt;/p&gt;

&lt;p&gt;That is the worst kind of failure: revenue logic that silently switches off while the system around it reports success. If you have a Script live today, &lt;strong&gt;June 30 is not a migration deadline — it is the day a piece of your checkout silently changes behavior.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A deployed Function does nothing until a discount node exists in the Admin
&lt;/h2&gt;

&lt;p&gt;So you migrate. You rebuild the Script as a Shopify Function — WebAssembly, the official replacement. You write the logic, &lt;code&gt;npm run deploy&lt;/code&gt;, the Function shows up in your Partner Dashboard. Done?&lt;/p&gt;

&lt;p&gt;No. And this is the trap that catches teams who think a Function is a drop-in for a Script.&lt;/p&gt;

&lt;p&gt;A Script ran the moment it was saved in the Script Editor. A &lt;strong&gt;Function does not run just because it is deployed.&lt;/strong&gt; A discount Function only executes when a &lt;em&gt;discount&lt;/em&gt; is attached to it — a &lt;code&gt;DiscountAutomaticApp&lt;/code&gt; or &lt;code&gt;DiscountCodeApp&lt;/code&gt; node, created in the Shopify Admin (or via the &lt;code&gt;discountAutomaticAppCreate&lt;/code&gt; GraphQL mutation) and set to &lt;strong&gt;Active&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Deploy the Function, forget to create the discount node — or create it and leave it in Draft, or with a future start date — and the result is exactly the same as section 1: checkout runs, completes, charges full price. The Function exists. It is just never invoked. There is no error, because nothing called anything.&lt;/p&gt;

&lt;p&gt;This is the half-migration failure mode. The Script is gone, the Function is "deployed," the dashboard looks green, and discounts are silently off. Confirm the discount node exists, is &lt;strong&gt;Active&lt;/strong&gt;, and has a start date in the past. The Function is only half the wiring.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The input query: a field you forget to request comes back &lt;code&gt;null&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Scripts and Functions receive cart data completely differently, and this is where logic silently no-ops.&lt;/p&gt;

&lt;p&gt;A Ruby Script got the whole cart handed to it — &lt;code&gt;Input.cart.line_items&lt;/code&gt;, customer, tags, everything in scope. A Function gets only what its &lt;strong&gt;input query&lt;/strong&gt; explicitly asks for. The input query is a GraphQL document you write; Shopify runs it and passes the result to your Function as JSON.&lt;/p&gt;

&lt;p&gt;The rule that bites: &lt;strong&gt;a field you do not request in the input query is &lt;code&gt;null&lt;/code&gt; in your Function.&lt;/strong&gt; Not an error. Not a warning. &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So you migrate a Script that gives a discount to customers tagged &lt;code&gt;wholesale&lt;/code&gt;. In the Function you write the discount logic correctly — but your input query doesn't request &lt;code&gt;cart.buyerIdentity.customer.hasTags&lt;/code&gt; (or the metafield, or the product tags, or the line-item &lt;code&gt;sellingPlanAllocation&lt;/code&gt;, whatever your rule keys on). The Function runs. It reads the customer tags. They are &lt;code&gt;null&lt;/code&gt;. The &lt;code&gt;null&lt;/code&gt; doesn't match &lt;code&gt;wholesale&lt;/code&gt;. It returns "no discount."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;200 OK&lt;/code&gt;. Order completes. Full price. The Function ran &lt;em&gt;successfully&lt;/em&gt; — it just made its decision on data that was silently absent. Every conditional discount you migrate has this exposure: the condition you branch on must be in the input query, or your branch quietly takes the wrong path.&lt;/p&gt;

&lt;p&gt;When you migrate a Script, write down every field its logic reads, and check each one is in the input query. The dangerous field is the one your logic depends on and your query forgot.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;combinesWith&lt;/code&gt; and discount strategy: a Script that always applied may now lose
&lt;/h2&gt;

&lt;p&gt;A Script applied its discount &lt;strong&gt;unconditionally&lt;/strong&gt; — the Ruby ran, the discount landed, end of story. A Function discount does not get that guarantee. It lives under two layers of Shopify-side arbitration that Scripts never had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;combinesWith&lt;/code&gt;&lt;/strong&gt; is configured on the discount &lt;em&gt;node&lt;/em&gt;, not in your Function code. It declares whether this discount can stack with order / product / shipping discounts. If you had a Script that gave &lt;em&gt;both&lt;/em&gt; a line-item discount &lt;em&gt;and&lt;/em&gt; an order-level discount, and you rebuild it as two Functions whose discount nodes are not configured to combine with each other — only one applies. The other is silently dropped. Your Function code is correct; the node configuration is the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;discountApplicationStrategy&lt;/code&gt;&lt;/strong&gt; decides which discount wins per line item — &lt;code&gt;FIRST&lt;/code&gt;, &lt;code&gt;MAXIMUM&lt;/code&gt;, or &lt;code&gt;ALL&lt;/code&gt;. A Script that always stacked its discount on top may become a Function that only applies when it is the &lt;em&gt;best&lt;/em&gt; discount on that line. On a cart that already has a better discount, yours silently does not apply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this errors. The checkout completes, an order is created, &lt;em&gt;a&lt;/em&gt; discount may even show — just not the combination your Script produced. If your Script's behavior depended on stacking, your Function's behavior now depends on the node config and the strategy matching it. Verify both against a real multi-discount cart.&lt;/p&gt;

&lt;p&gt;One more: if you are following an older tutorial, make sure you build against the unified &lt;strong&gt;Discount Function API&lt;/strong&gt;, not the legacy split &lt;em&gt;Order Discount&lt;/em&gt; / &lt;em&gt;Product Discount&lt;/em&gt; Function APIs — those are themselves deprecated. Migrating onto an already-deprecated target is a migration you get to do twice.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inventory your Scripts now.&lt;/strong&gt; In the Shopify admin, open the Script Editor (Apps → Script Editor) and the &lt;strong&gt;Shopify Scripts customizations report&lt;/strong&gt;. List every live Script and exactly what it modifies — discount, shipping, or payment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For each Script, decide the replacement explicitly.&lt;/strong&gt; A Shopify Function, a public app from the App Store, or &lt;em&gt;consciously dropping it&lt;/em&gt;. The danger is the Script nobody owns — it just stops on June 30 and no one is watching the metric it moved.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat "deployed" and "live" as two different states.&lt;/strong&gt; For every discount Function: confirm the discount node exists, is &lt;strong&gt;Active&lt;/strong&gt;, has a past start date, and has &lt;code&gt;combinesWith&lt;/code&gt; set to match the stacking your Script did.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Diff the input query against the Script's logic.&lt;/strong&gt; Every field the Ruby read must be in the Function's input query. Anything missing is &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; silently changes which branch your discount logic takes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test on real carts before June 30, not after.&lt;/strong&gt; Build the carts your Scripts actually targeted — the wholesale customer, the PO box address, the high-risk payment cart, the multi-discount cart — and run a full checkout. Confirm the final price, the shipping options, and the payment methods match what the Script produced. This is the only check that catches a silent no-op.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Watch the margin metric across the cutover.&lt;/strong&gt; Average discount per order, blended shipping cost, payment-method mix. If a Script silently stops, these move before any human notices. A dashboard line is cheaper than a chargeback.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model retirements get headlines because they fail loud. Shopify Scripts will fail quiet — checkout will keep completing, orders will keep flowing, and the only signal that something broke is a number in a report moving the wrong way. Plan for the quiet failure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like this one — deprecations, response-shape changes, and silently-dropped behavior — and surfaces them before they reach production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ecommerce</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Google retires every Imagen model on June 24 — and the Gemini image migration fails silently in 4 places</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 10 Jun 2026 05:00:36 +0000</pubDate>
      <link>https://dev.to/flarecanary/google-retires-every-imagen-model-on-june-24-and-the-gemini-image-migration-fails-silently-in-4-paf</link>
      <guid>https://dev.to/flarecanary/google-retires-every-imagen-model-on-june-24-and-the-gemini-image-migration-fails-silently-in-4-paf</guid>
      <description>&lt;p&gt;On &lt;strong&gt;June 24, 2026&lt;/strong&gt;, Google shuts down every remaining Imagen model on the Gemini API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;imagen-4.0-generate-001&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;imagen-4.0-ultra-generate-001&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;imagen-4.0-fast-generate-001&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(&lt;code&gt;imagen-3.0-generate-002&lt;/code&gt; already went dark on November 10, 2025 — if you somehow survived that one, you were already on borrowed time.)&lt;/p&gt;

&lt;p&gt;The recommended replacement is &lt;code&gt;gemini-2.5-flash-image&lt;/code&gt; — the model Google markets as "Nano Banana" — or &lt;code&gt;gemini-3-pro-image-preview&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the part that makes this dangerous. The &lt;strong&gt;retirement itself is loud&lt;/strong&gt;: after June 24, a request to &lt;code&gt;imagen-4.0-generate-001&lt;/code&gt; returns an error, your pipeline throws, someone gets paged. That's fine. Loud failures get fixed.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;migration is not loud&lt;/strong&gt;. Imagen and Gemini's image generation are not the same API with a different model string — they are two different request shapes, two different endpoints, and two different parameter vocabularies. And Gemini's image endpoint will happily accept a request full of Imagen-era parameters, ignore the ones it doesn't recognize, and return &lt;code&gt;200 OK&lt;/code&gt; with an image. Not the image you asked for. &lt;em&gt;An&lt;/em&gt; image.&lt;/p&gt;

&lt;p&gt;That's the trap. Teams will migrate under deadline pressure, see images come back, see green status codes, and ship. The defects land downstream, weeks later, with no error attached.&lt;/p&gt;

&lt;p&gt;Here's the silent-fail surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The endpoint and response shape changed — and a half-migrated parser fails quiet
&lt;/h2&gt;

&lt;p&gt;Imagen runs on the &lt;strong&gt;&lt;code&gt;:predict&lt;/code&gt;&lt;/strong&gt; endpoint. You send &lt;code&gt;instances&lt;/code&gt; and &lt;code&gt;parameters&lt;/code&gt;, and you get back:&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;"predictions"&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;"bytesBase64Encoded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"mimeType"&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/png"&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;Gemini image generation runs on &lt;strong&gt;&lt;code&gt;:generateContent&lt;/code&gt;&lt;/strong&gt;. You send &lt;code&gt;contents&lt;/code&gt; with &lt;code&gt;parts&lt;/code&gt;, and the image comes back at:&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;"candidates"&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;"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="nl"&gt;"parts"&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;"inlineData"&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;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"mimeType"&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/png"&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;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you point your code at the new model but keep calling &lt;code&gt;:predict&lt;/code&gt;, you get a loud error — good. But the failure that actually ships is the &lt;em&gt;half&lt;/em&gt; migration: you move to &lt;code&gt;:generateContent&lt;/code&gt;, and a downstream helper still reaches for &lt;code&gt;response.predictions[0].bytesBase64Encoded&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That path doesn't exist on a Gemini response. In JavaScript it evaluates to &lt;code&gt;undefined&lt;/code&gt;. In Python it's a &lt;code&gt;KeyError&lt;/code&gt; only if you index hard — and most image-handling code doesn't, because it was written defensively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;img&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="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;predictions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[{}])[&lt;/span&gt;&lt;span class="mi"&gt;0&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;bytesBase64Encoded&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;img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;predictions&lt;/code&gt; is missing, so &lt;code&gt;img&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;, so the &lt;code&gt;if&lt;/code&gt; is skipped. No exception. No image written. &lt;code&gt;200 OK&lt;/code&gt; logged. The function returns cleanly. You find out when someone notices the asset bucket stopped filling — and by then you can't tell which day it started.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;sampleCount&lt;/code&gt; is gone — your batch silently collapses to one image per call
&lt;/h2&gt;

&lt;p&gt;This is the sharpest one.&lt;/p&gt;

&lt;p&gt;Imagen's &lt;code&gt;:predict&lt;/code&gt; request takes a &lt;code&gt;sampleCount&lt;/code&gt; parameter: ask for up to 4 images in a single call (&lt;code&gt;imagen-4.0-generate-001&lt;/code&gt;), or 1 at a time on the Ultra tier. Plenty of pipelines lean on this — generate 4 candidates per prompt, score them, keep the best. The &lt;code&gt;sampleCount: 4&lt;/code&gt; is load-bearing.&lt;/p&gt;

&lt;p&gt;Gemini's image API has no &lt;code&gt;sampleCount&lt;/code&gt;. It generates &lt;strong&gt;one image per call&lt;/strong&gt;, and Google's own documentation is blunt about it: &lt;em&gt;"The model won't always follow the exact number of image outputs that the user explicitly asks for."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So when your migrated request carries &lt;code&gt;sampleCount: 4&lt;/code&gt; — or you ask conversationally for "four variations" — Gemini doesn't error. It returns one image, &lt;code&gt;200 OK&lt;/code&gt;. Your candidate pool just dropped from 4 to 1. The "pick the best of 4" step still runs; it's now picking the best of 1. Output quality quietly degrades, throughput math is off by 4×, and nothing in the response says "you asked for four and got one."&lt;/p&gt;

&lt;p&gt;If any part of your system reasons about &lt;em&gt;how many&lt;/em&gt; images it gets back, that assumption is now wrong, and it's wrong silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Aspect ratio moved namespaces — and became a suggestion
&lt;/h2&gt;

&lt;p&gt;Imagen takes &lt;code&gt;aspectRatio&lt;/code&gt; as a top-level entry in &lt;code&gt;parameters&lt;/code&gt;, and it's a hard constraint: ask for &lt;code&gt;16:9&lt;/code&gt;, get &lt;code&gt;16:9&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On Gemini image generation, aspect ratio is &lt;strong&gt;not&lt;/strong&gt; a top-level parameter. It lives nested inside an image-config block on the generation config. Carry the Imagen-style top-level &lt;code&gt;aspectRatio&lt;/code&gt; field straight over and it lands nowhere — it's an unrecognized key, silently dropped, and Gemini falls back to its default (square, or whatever it infers from the prompt).&lt;/p&gt;

&lt;p&gt;Result: &lt;code&gt;200 OK&lt;/code&gt;, a perfectly valid image, in the wrong dimensions. Every downstream consumer that assumed a fixed aspect ratio — a CSS grid, a video frame, a thumbnail cropper, a print layout — now gets a shape it wasn't built for. Best case it looks wrong. Worst case the cropper "fixes" it by cutting the subject's head off, automatically, with no error.&lt;/p&gt;

&lt;p&gt;And even when you &lt;em&gt;do&lt;/em&gt; nest the field correctly, treat it as a strong hint rather than a guarantee. There are documented reports of Gemini image models returning a 1:1 square for an explicit &lt;code&gt;16:9&lt;/code&gt; request. Validate the dimensions of what comes back; don't trust that you got what you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;personGeneration&lt;/code&gt; has no clean equivalent — your safety posture shifts without a diff
&lt;/h2&gt;

&lt;p&gt;Imagen exposes a &lt;code&gt;personGeneration&lt;/code&gt; parameter: &lt;code&gt;dont_allow&lt;/code&gt;, &lt;code&gt;allow_adult&lt;/code&gt;, &lt;code&gt;allow_all&lt;/code&gt;. Teams set this deliberately — a kids' education product pins &lt;code&gt;dont_allow&lt;/code&gt;; an internal tool runs &lt;code&gt;allow_all&lt;/code&gt;. It's a compliance decision, often written down in a review somewhere.&lt;/p&gt;

&lt;p&gt;Gemini's image API doesn't have a &lt;code&gt;personGeneration&lt;/code&gt; knob in that shape. It folds people-generation behavior into the general model safety stack. So &lt;code&gt;personGeneration&lt;/code&gt; on a migrated request is — you'll notice the pattern by now — an unrecognized key, silently dropped.&lt;/p&gt;

&lt;p&gt;What you're left with is Gemini's default people-generation behavior, which is &lt;strong&gt;not&lt;/strong&gt; guaranteed to match the Imagen setting you carefully chose. A pipeline pinned to &lt;code&gt;dont_allow&lt;/code&gt; may start returning images with people in them. A pipeline that relied on &lt;code&gt;allow_all&lt;/code&gt; may start refusing prompts it used to honor. Either way, &lt;code&gt;200 OK&lt;/code&gt;, no warning, and a safety/compliance posture that changed without anyone editing the line that used to control it. This is the surface a security review would actually care about, and it has no error and no schema diff to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The two things to also budget for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prompt rewriting.&lt;/strong&gt; Imagen runs an LLM-based prompt rewriter by default — it silently expands your terse prompt before generating. Gemini handles prompts natively and differently. Migrate, send the &lt;em&gt;byte-identical&lt;/em&gt; prompt, and the image style, composition, and detail level shift — because the prewriting layer your results were implicitly tuned against is gone. Nothing breaks; your outputs just drift. If you have a brand or style baseline, re-approve it after migrating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mask-based editing has no replacement.&lt;/strong&gt; This is the migration some teams can't actually make. Imagen's capability/editing model does precise, programmatic mask-based inpainting and outpainting — you supply a reference image plus a mask, and edits land exactly inside the mask. Gemini 2.5 Flash Image does &lt;em&gt;conversational&lt;/em&gt; editing: you describe the change in words. There is no pixel-precise mask parameter. If you have a pipeline that does deterministic mask-driven edits — product photography, automated retouching, compositing — there is no drop-in path. Find this out now, in June, not in a sprint planning meeting in July.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for the dead model IDs across everything&lt;/strong&gt; — app code, IaC, notebooks, prompt configs, batch-job definitions:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"imagen-(4&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;0-(generate|ultra-generate|fast-generate)-001|3&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;0)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat this as a rewrite, not a string swap.&lt;/strong&gt; The endpoint, request shape, and response shape all change. Budget for it like the API migration it is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit every Imagen-era parameter against the new API.&lt;/strong&gt; Make a literal checklist: &lt;code&gt;sampleCount&lt;/code&gt;, &lt;code&gt;aspectRatio&lt;/code&gt;, &lt;code&gt;personGeneration&lt;/code&gt;, prompt-rewrite behavior. For each, confirm it either has a real equivalent you've wired up, or accept — explicitly, in writing — that you're losing it. The danger is the parameter you neither migrate nor consciously drop.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assert on the response, don't assume it.&lt;/strong&gt; After migrating, check the &lt;em&gt;count&lt;/em&gt; of images returned and the &lt;em&gt;dimensions&lt;/em&gt; of each one against what you requested. Both can silently differ. A three-line assertion catches sections 2 and 3 of this post.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-approve your output baseline.&lt;/strong&gt; Prompt rewriting is gone and the underlying model is different. Whatever "looks right" meant for your product, re-establish it against real Gemini output before June 24 — not after.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model retirement is the loud part, and the loud part will get handled. The migration is the quiet part. Plan for the quiet part.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like this one — model retirements, response-shape changes, and silently-dropped parameters — and surfaces them before they reach production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>google</category>
      <category>gemini</category>
      <category>imagen</category>
      <category>ai</category>
    </item>
    <item>
      <title>Atlassian Admin v1 APIs Go Dark on June 30 — Your User-Provisioning Script Probably Hits Eleven of Them</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 07 Jun 2026 05:00:42 +0000</pubDate>
      <link>https://dev.to/flarecanary/atlassian-admin-v1-apis-go-dark-on-june-30-your-user-provisioning-script-probably-hits-eleven-of-l0n</link>
      <guid>https://dev.to/flarecanary/atlassian-admin-v1-apis-go-dark-on-june-30-your-user-provisioning-script-probably-hits-eleven-of-l0n</guid>
      <description>&lt;p&gt;If your team has any custom Atlassian Cloud admin automation — IdP sync, SCIM glue, lifecycle scripts, group provisioning — it almost certainly hits at least one of the eleven endpoints under &lt;code&gt;/admin/v1/orgs/{orgId}/...&lt;/code&gt;. After &lt;strong&gt;June 30, 2026&lt;/strong&gt;, those endpoints are gone.&lt;/p&gt;

&lt;p&gt;Atlassian announced the v1 sunset alongside the new v2 Directory/Users/Groups APIs. The migration is documented; the part that isn't documented loudly is that v2 isn't a drop-in path swap. The shape of the URL changed, the auth model picked up an extra resolution step, and several common automation patterns become two-call sequences instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eleven endpoints to grep for
&lt;/h2&gt;

&lt;p&gt;User-management endpoints going away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/users/search&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/users/{accountId}/suspend-access&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/users/{accountId}/restore-access&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DELETE /admin/v1/orgs/{orgId}/directory/users/{accountId}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Group-management endpoints going away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/groups/search&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/groups&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DELETE /admin/v1/orgs/{orgId}/directory/groups/{groupId}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/groups/{groupId}/roles/assign&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/groups/{groupId}/roles/revoke&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /admin/v1/orgs/{orgId}/directory/groups/{groupId}/memberships&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DELETE /admin/v1/orgs/{orgId}/directory/groups/{groupId}/memberships/{accountId}&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus &lt;code&gt;POST /admin/v1/orgs/{orgId}/users/invite&lt;/code&gt;, which Atlassian deprecated on January 13, 2026 — same June 30 sunset date.&lt;/p&gt;

&lt;p&gt;A literal grep on 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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"admin/v1/orgs"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.{py,js,ts,go,rb,sh}"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything comes back, it dies on June 30.&lt;/p&gt;

&lt;p&gt;This affects any tenant on &lt;strong&gt;centralized user management&lt;/strong&gt; — which, since 2025, has been the default for new orgs and the migration target for existing ones. If you don't know which user management mode your org is on, the v1 sunset still applies once the migration completes, so treat it as in-scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  What v2 actually looks like
&lt;/h2&gt;

&lt;p&gt;The v1 path was organization-rooted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/admin/v1/orgs/{orgId}/directory/users/{accountId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The v2 path is directory-rooted under an organization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/admin/v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;directoryId&lt;/code&gt; is not optional, and Atlassian's v1 callers never had to know it. Every script migrating off v1 needs an extra step to discover the directory before it can act on a user or group inside that directory.&lt;/p&gt;

&lt;p&gt;So a one-call workflow becomes a two-call workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. List directories for the org → pick a directoryId
2. Call the v2 endpoint with {orgId}/directories/{directoryId}/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For orgs with one directory, this is mostly mechanical. For orgs with multiple directories (Identity Manager, Google, Microsoft, plus a local directory), every script now has to disambiguate, and "the right one" depends on which IdP the user came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  OAuth scopes — one easy miss
&lt;/h2&gt;

&lt;p&gt;The Admin scope set didn't get a new "v2 scope." But scripts that previously only operated on users-by-account-id never had to declare &lt;code&gt;read:directories:admin&lt;/code&gt;. Now they do, because every v2 call passes through a directory resolution.&lt;/p&gt;

&lt;p&gt;Apps that don't add &lt;code&gt;read:directories:admin&lt;/code&gt; to their token request will get scope errors on the new endpoints — without the v1 endpoint being there to fall back to. The scope error happens at request time, not at auth time, so a token issued before the migration looks fine until the first call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the failures will look like
&lt;/h2&gt;

&lt;p&gt;Atlassian's announcement says v1 endpoints "will remain available until 30 June 2026. After this date, they will be fully deprecated." The exact response shape post-sunset isn't documented for &lt;code&gt;/admin/v1/orgs/...&lt;/code&gt;, but Atlassian's pattern on other removed REST APIs (Jira &lt;code&gt;/rest/api/3/search&lt;/code&gt; and friends) has been &lt;strong&gt;HTTP 410 Gone&lt;/strong&gt; with a JSON error along the lines of:&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;410&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;"The requested API has been removed. Please use the newer endpoints."&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 expected fingerprint, not a guarantee. If your client treats 410 as terminal (most retry libraries do), the failed call will not be retried, and the operation — suspend, remove, group-add — silently won't happen. The script returns "successful" on the calls that didn't go through the dead endpoint and "failed" on the ones that did, and a half-completed offboarding looks identical to a successful one in the audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The use cases that quietly break
&lt;/h2&gt;

&lt;p&gt;The Atlassian announcement explicitly calls out a handful of automation patterns that depend on the v1 endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offboarding scripts.&lt;/strong&gt; Calling &lt;code&gt;suspend-access&lt;/code&gt; and then &lt;code&gt;directory/users/{accountId}&lt;/code&gt; DELETE in sequence is the canonical Atlassian-side termination flow. Both endpoints are on the list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SCIM-adjacent IdP sync.&lt;/strong&gt; Many teams wrote custom sync because Atlassian's SCIM connector didn't cover their IdP, or didn't handle group memberships the way they wanted. Custom sync scripts overwhelmingly use the v1 group memberships endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk invite onboarding.&lt;/strong&gt; &lt;code&gt;POST /admin/v1/orgs/{orgId}/users/invite&lt;/code&gt; was the canonical way to invite a list of users with a default group set. Already deprecated since January.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group provisioning from CI.&lt;/strong&gt; Terraform-style "groups defined in code" pipelines that create/delete groups based on YAML — the create and delete endpoints are both on the list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The offboarding case is the one that goes wrong silently. A user gets removed from your IdP, the sync script runs, the v2 path fails because the script wasn't migrated, and the user keeps their Atlassian access until someone notices on the next access review. That's the nightmare scenario for security teams who track "removed in IdP within X hours" as a compliance metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do this quarter
&lt;/h2&gt;

&lt;p&gt;Three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory.&lt;/strong&gt; Grep &lt;code&gt;admin/v1/orgs&lt;/code&gt; across all internal repos, CI configs, and scripts on admin/ops machines. Capture which endpoints are in use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve directory IDs.&lt;/strong&gt; Build (or buy) a small helper that lists directories on the org and returns the directory ID for a given user — every v2 call needs this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the &lt;code&gt;read:directories:admin&lt;/code&gt; scope to your token requests&lt;/strong&gt; before you migrate the calls. The scope addition is forward-compatible with v1 calls, so you can add it without breaking anything before you cut over.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your custom sync also uses Jira's &lt;code&gt;/rest/api/3/search&lt;/code&gt; or any other v3 Jira API marked for removal, batch them — same orgs, same teams, same maintenance window.&lt;/p&gt;

&lt;p&gt;The pattern here is the one we keep seeing across providers: a deprecation notice goes out, the SDK or admin script gets an "available until" date that everyone marks on a sticky note, and on the day the API returns 410 the team finds out which scripts they actually shipped to production years ago. We built &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; to flag the response-shape and field-semantic changes that don't even show up as a deprecation notice — but on this one, the calendar entry for &lt;strong&gt;June 30, 2026&lt;/strong&gt; is the one to set.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your offboarding flow uses any v1 endpoint, treat it as the highest-priority migration. Half-completed user removals are an audit problem long before they're a security problem.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>atlassian</category>
      <category>api</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Auth0 removes enabled_clients from connection reads July 13 — Terraform/Pulumi will silently see every client as disabled</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Thu, 04 Jun 2026 05:00:42 +0000</pubDate>
      <link>https://dev.to/flarecanary/auth0-removes-enabledclients-from-connection-reads-july-13-terraformpulumi-will-silently-see-51hj</link>
      <guid>https://dev.to/flarecanary/auth0-removes-enabledclients-from-connection-reads-july-13-terraformpulumi-will-silently-see-51hj</guid>
      <description>&lt;p&gt;If you manage Auth0 connections through Terraform, Pulumi, or any in-house multi-tenant provisioning script, there's a quiet failure mode landing on &lt;strong&gt;July 13, 2026&lt;/strong&gt;. On that date, Auth0 removes the &lt;code&gt;enabled_clients&lt;/code&gt; field from &lt;code&gt;GET /api/v2/connections&lt;/code&gt; and &lt;code&gt;GET /api/v2/connections/{id}&lt;/code&gt; responses, and stops accepting &lt;code&gt;enabled_clients&lt;/code&gt; in &lt;code&gt;PATCH /api/v2/connections/{id}&lt;/code&gt;. The field was deprecated January 13, 2026; July 13 is end-of-life.&lt;/p&gt;

&lt;p&gt;Nothing returns an error. Calls still get a &lt;code&gt;200&lt;/code&gt;. The connection object comes back, with all the same fields you've always read — minus one. The code that reads &lt;code&gt;connection.enabled_clients&lt;/code&gt; to figure out which apps are wired to a connection gets either an empty array or &lt;code&gt;undefined&lt;/code&gt;, depending on how Auth0 serializes the absence. Either way, your IaC plan, your drift-detection job, or your client-provisioning script reads "no clients enabled" and reacts accordingly. That reaction is usually one of two failure modes, both bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Silent Failure Modes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mode 1: Drift-detection wipe
&lt;/h3&gt;

&lt;p&gt;Terraform's &lt;code&gt;auth0_connection&lt;/code&gt; resource (and the Pulumi equivalent) include &lt;code&gt;enabled_clients&lt;/code&gt; as a managed attribute. On every &lt;code&gt;plan&lt;/code&gt;, the provider calls &lt;code&gt;GET /api/v2/connections/{id}&lt;/code&gt;, reads the current &lt;code&gt;enabled_clients&lt;/code&gt;, and compares it to what's in your state file. After July 13, the read returns empty, the state file still lists the configured set, and the provider sees a delta. Then &lt;code&gt;apply&lt;/code&gt; calls &lt;code&gt;PATCH&lt;/code&gt; to "fix" it — sending the configured &lt;code&gt;enabled_clients&lt;/code&gt; list back. The PATCH ignores the field (it's deprecated for input too), the connection's client associations stay whatever they actually are, and the next &lt;code&gt;plan&lt;/code&gt; shows the same drift again.&lt;/p&gt;

&lt;p&gt;The dangerous variant: if you've ever managed Auth0 partly through Terraform and partly through the dashboard or a separate provisioning tool, your state file lists &lt;em&gt;only the clients Terraform knows about&lt;/em&gt;. A drift-detection run that decides "the source of truth is my state file" — common in CI pipelines that auto-apply — will silently issue a corrective change. Until July 13, that worked: PATCH with the desired &lt;code&gt;enabled_clients&lt;/code&gt; reconciled the difference. After July 13, the PATCH silently no-ops, so Terraform-managed connections look right in the state but actually still have the out-of-band associations intact. If you then &lt;em&gt;also&lt;/em&gt; run the new endpoint-based code to "clean up disabled clients," you can wipe the out-of-band ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mode 2: Multi-tenant provisioning over-restriction
&lt;/h3&gt;

&lt;p&gt;A common multi-tenant pattern in SaaS apps that white-label Auth0: when a new tenant signs up, your control plane creates an Auth0 client for them and enables existing connections for that client. The reverse — enumerating which connections each client is enabled on — is usually done by walking all connections, reading &lt;code&gt;enabled_clients&lt;/code&gt;, and filtering by the tenant's client_id.&lt;/p&gt;

&lt;p&gt;After July 13, that walk returns empty &lt;code&gt;enabled_clients&lt;/code&gt; for every connection. Code that interprets the empty list as "this connection has no enabled clients" and then "remediates" by re-enabling everything from a desired-state list hits either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A no-op (the PATCH ignores the field, the actual associations don't change, the tenant works fine in production but every audit report says they're disabled).&lt;/li&gt;
&lt;li&gt;A retry storm (if the script keeps trying to re-enable until &lt;code&gt;GET&lt;/code&gt; confirms the change, which never happens because the field is gone).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, observability dashboards keyed on &lt;code&gt;enabled_clients&lt;/code&gt; go dark or flatline. SCIM-style sync jobs that reconcile Auth0 against an external IdP source-of-truth start over-eagerly trying to fix a discrepancy that isn't real.&lt;/p&gt;

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

&lt;p&gt;The new endpoints, available now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;GET /api/v2/connections/{id}/clients&lt;/code&gt;&lt;/strong&gt; — returns the enabled clients for a connection, paginated.&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;"clients"&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;"client_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;"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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"client_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;"def456..."&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;"next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"opaque_cursor"&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;Query params: &lt;code&gt;take&lt;/code&gt; (1–1000, default 50), &lt;code&gt;from&lt;/code&gt; (cursor; omit on first call). When &lt;code&gt;next&lt;/code&gt; is absent in the response, you've walked the full set. Required scope: &lt;code&gt;read:connections&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;PATCH /api/v2/connections/{id}/clients&lt;/code&gt;&lt;/strong&gt; — toggle enabled status for specific clients.&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"client_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;"abc123..."&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="kc"&gt;true&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;"client_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;"def456..."&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="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;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;Returns &lt;code&gt;204 No Content&lt;/code&gt; on success. Required scope: &lt;code&gt;update:connections&lt;/code&gt;. The hard constraint that bites bulk operations: &lt;strong&gt;maximum 50 clients per request&lt;/strong&gt;. If your provisioning code enables a connection for all 200 clients in a tenant batch via one &lt;code&gt;enabled_clients&lt;/code&gt; PATCH today, the equivalent through the new endpoint is four sequential calls. Naive ports that don't chunk silently truncate at 50 (or, depending on the API, return &lt;code&gt;400&lt;/code&gt; — the docs don't pin this down).&lt;/p&gt;

&lt;p&gt;The new PATCH is also &lt;em&gt;selective&lt;/em&gt;, not &lt;em&gt;replacing&lt;/em&gt;. The old &lt;code&gt;enabled_clients&lt;/code&gt; was a full-set replacement: PATCH with &lt;code&gt;["client_a", "client_b"]&lt;/code&gt; made those exactly the enabled set. The new endpoint only toggles the clients you list. Anything not in your PATCH array keeps its current status. Tools that assumed PATCH = full replacement will now leave stale enables in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Slips Through Normal Detection
&lt;/h2&gt;

&lt;p&gt;Walk the standard checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP status check&lt;/strong&gt; — passes. &lt;code&gt;GET /api/v2/connections/{id}&lt;/code&gt; still returns 200. The connection object is still there, just without the &lt;code&gt;enabled_clients&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validation&lt;/strong&gt; — passes. &lt;code&gt;enabled_clients&lt;/code&gt; was always an optional field for connections that hadn't been wired to any client yet. A response without it is structurally valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform plan&lt;/strong&gt; — &lt;em&gt;thinks&lt;/em&gt; it caught it. The provider sees the configured &lt;code&gt;enabled_clients&lt;/code&gt; is no longer present in the remote state and shows a diff. The diff says "will add three clients." &lt;code&gt;apply&lt;/code&gt; runs, the PATCH succeeds (&lt;code&gt;200&lt;/code&gt;, the deprecated field is silently dropped), and the next &lt;code&gt;plan&lt;/code&gt; shows the &lt;em&gt;same&lt;/em&gt; diff. To an operator, this looks like Terraform fighting with another process — not like a deprecated field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI / unit tests&lt;/strong&gt; — pass if they mock Auth0. Anyone who tests against a recorded fixture from before July 13 sees the same field they always saw. Tests don't notice until they hit real Auth0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration audits&lt;/strong&gt; — only catch it if you specifically look for &lt;code&gt;enabled_clients&lt;/code&gt; in your codebase. Most audits look for endpoint URL changes, not field-level removals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Terraform Auth0 provider will probably ship a release that switches &lt;code&gt;enabled_clients&lt;/code&gt; to use the new endpoints under the hood — but the release calendar isn't Auth0's, it's HashiCorp's (or the community maintainers'). Pinning to an older provider version with explicit &lt;code&gt;enabled_clients&lt;/code&gt; config delays the migration into the deprecation window without addressing it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Grep for &lt;code&gt;enabled_clients&lt;/code&gt; across your infra repos.&lt;/strong&gt; Every match is a migration site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform &lt;code&gt;auth0_connection&lt;/code&gt; resources with &lt;code&gt;enabled_clients = [...]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pulumi &lt;code&gt;auth0.Connection&lt;/code&gt; resources with &lt;code&gt;enabledClients: [...]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Direct API calls — anything reading &lt;code&gt;.enabled_clients&lt;/code&gt; off a connection response, or sending &lt;code&gt;enabled_clients&lt;/code&gt; in a PATCH body&lt;/li&gt;
&lt;li&gt;SDK calls: &lt;code&gt;managementClient.connections.update(..., { enabled_clients: ... })&lt;/code&gt; in node-auth0 / auth0-python / auth0.net&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Verify the new endpoints work for your scopes.&lt;/strong&gt; The new &lt;code&gt;GET /api/v2/connections/{id}/clients&lt;/code&gt; requires &lt;code&gt;read:connections&lt;/code&gt;, which most existing M2M tokens already have. The PATCH requires &lt;code&gt;update:connections&lt;/code&gt;. If you've used a scoped-down token in CI that only had &lt;code&gt;read:client_grants&lt;/code&gt; or similar, you may need to expand it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Chunk PATCHes by 50.&lt;/strong&gt; Wherever you currently send a full &lt;code&gt;enabled_clients&lt;/code&gt; array, the replacement code has to enumerate the desired client_ids, diff against the current set (paginated via the new GET), and issue &lt;code&gt;PATCH /clients&lt;/code&gt; calls in chunks of 50 with &lt;code&gt;status: true&lt;/code&gt; / &lt;code&gt;status: false&lt;/code&gt; per client. Bulk operations that don't chunk break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Treat missing &lt;code&gt;enabled_clients&lt;/code&gt; as a sentinel during migration.&lt;/strong&gt; If your code path can run before or after July 13 (a slow CI matrix, a paused tenant, a long-lived M2M token still hitting an older runtime), add an explicit check: if &lt;code&gt;enabled_clients&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; on the connection response, fall back to the new endpoint instead of treating it as "no clients enabled." That single guard makes the cutover safe regardless of which side of July 13 the call lands on.&lt;/p&gt;

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

&lt;p&gt;A vendor moves a piece of state from "inlined on a parent resource" to "dedicated sub-resource endpoints." The parent endpoint keeps working. The field just stops being there. Code that assumed &lt;em&gt;presence&lt;/em&gt; of the field as "this connection has clients" and &lt;em&gt;absence&lt;/em&gt; as "this connection has no clients" reads the new absence as the old "no clients" — and acts on it.&lt;/p&gt;

&lt;p&gt;The same shape shows up across cloud providers and SaaS APIs every few months. The mitigation isn't to track every deprecation memo; it's to treat field absence as ambiguous (was it removed? was it always empty? was it filtered out by a scope?) and require an explicit signal before reacting. After July 13, "absent" on Auth0 connection responses means "go ask the new endpoint" — not "no clients enabled."&lt;/p&gt;

&lt;p&gt;If you run Auth0 through IaC, the cheapest move this month is to grep for &lt;code&gt;enabled_clients&lt;/code&gt;, write the new-endpoint reads alongside the old-field reads, and gate the cutover on a feature flag you can flip per-environment. Eight weeks of runway is enough to do it carefully; the alternative is debugging silent drift on a Monday in July.&lt;/p&gt;

</description>
      <category>auth0</category>
      <category>api</category>
      <category>terraform</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Shopify Changed heldBy From a String to an Object in 2025-01 — Your Query Still Returns 200 OK</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 01 Jun 2026 05:00:36 +0000</pubDate>
      <link>https://dev.to/flarecanary/shopify-changed-heldby-from-a-string-to-an-object-in-2025-01-your-query-still-returns-200-ok-1bc0</link>
      <guid>https://dev.to/flarecanary/shopify-changed-heldby-from-a-string-to-an-object-in-2025-01-your-query-still-returns-200-ok-1bc0</guid>
      <description>&lt;p&gt;Shopify ships a new GraphQL Admin API version every quarter. Most releases are additive. A few are not. The 2025-01 release is one of the "are not": it removed a handful of fields that thousands of apps were reading, and it quietly changed the type of one of the most common fulfillment fields from a string to an object.&lt;/p&gt;

&lt;p&gt;The endpoint still returns &lt;code&gt;200 OK&lt;/code&gt;. The query still parses. The field your code reads just isn't there anymore — or it's there under a new name with a completely different shape.&lt;/p&gt;

&lt;p&gt;If you upgraded a Shopify app from 2024-x to 2025-01 without reading the full release notes and you're seeing "this field is empty now" in production, this is probably why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heldBy → heldByApp Change
&lt;/h2&gt;

&lt;p&gt;On a &lt;code&gt;FulfillmentHold&lt;/code&gt; object, the 2024-x GraphQL schema returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;fulfillmentHold&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;heldBy&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# String! — "PrepSupport" or "ShopifyFulfillmentNetwork" or an app name&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of 2025-01, &lt;code&gt;heldBy&lt;/code&gt; is gone. In its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;fulfillmentHold&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;heldByApp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c"&gt;# String! — "PrepSupport"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c"&gt;# ID!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://shopify.dev/docs/api/release-notes/2025-01" rel="noopener noreferrer"&gt;2025-01 release notes&lt;/a&gt; put it like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you currently query &lt;code&gt;fulfillmentHold.heldBy&lt;/code&gt;, then transition to querying &lt;code&gt;fulfillmentHold.heldByApp.title&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The important thing is what happens if you don't. GraphQL in this version will happily return &lt;code&gt;null&lt;/code&gt; for a field it no longer recognizes (depending on your client's error handling), or it will throw a field-level error that your resolver swallows. Your downstream code — the logic that routes held orders to a human, the Slack notification that names the blocking app, the dashboard tile that shows "held by X" — starts quietly producing empty strings or "Unknown."&lt;/p&gt;

&lt;p&gt;Type systems don't save you here, either. Most Shopify SDKs are either hand-rolled TypeScript types generated from the schema at some pinned version, or they're &lt;code&gt;any&lt;/code&gt;-shaped because the developer didn't codegen against the current version. If your schema snapshot is from 2024-10, &lt;code&gt;heldBy&lt;/code&gt; is still in your types, your IDE still autocompletes it, and your unit tests (mocking your own types) pass. The mismatch only shows up at runtime, against Shopify's live API.&lt;/p&gt;

&lt;h2&gt;
  
  
  PrivateMetafield Is Just Gone
&lt;/h2&gt;

&lt;p&gt;The second big removal in 2025-01:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;PrivateMetafield&lt;/code&gt; is removed from the public GraphQL Admin API. Use app-data metafields instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is no field rename, no transitional alias, no "deprecated for N versions" wind-down. Every query, mutation, and resolver that referenced &lt;code&gt;PrivateMetafield&lt;/code&gt;, &lt;code&gt;privateMetafields&lt;/code&gt;, &lt;code&gt;privateMetafieldUpsert&lt;/code&gt;, or the &lt;code&gt;MetafieldStorefrontVisibility&lt;/code&gt; object stops working.&lt;/p&gt;

&lt;p&gt;If your app was using private metafields to store per-shop configuration — credentials, feature flags, per-merchant routing rules — that configuration didn't move anywhere. You have to migrate it to app-data metafields yourself, re-grant access via the new app-reserved namespace scheme, and replace every read and write. And until you do, the queries come back empty.&lt;/p&gt;

&lt;p&gt;The reason this is a silent-failure story and not a loud one is that GraphQL errors on unknown types usually manifest as empty result sets plus a &lt;code&gt;"errors": [...]&lt;/code&gt; array in the response body. Plenty of client libraries treat empty results as a valid zero-length response and log the error array at debug level. Your code reads &lt;code&gt;data.app.privateMetafields.edges&lt;/code&gt;, gets &lt;code&gt;[]&lt;/code&gt;, and moves on. The merchant's configuration just looks like it was never set.&lt;/p&gt;

&lt;h2&gt;
  
  
  accountNumber and routingNumber, Also Gone
&lt;/h2&gt;

&lt;p&gt;A smaller but spicier one, buried in the same release:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Removed &lt;code&gt;accountNumber&lt;/code&gt; and &lt;code&gt;routingNumber&lt;/code&gt; fields from &lt;code&gt;ShopifyPaymentsBankAccount&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your app was reading bank account details from Shopify Payments — for displaying masked account info, for reconciling payouts against external bookkeeping, for compliance paperwork — those two fields no longer exist. The field resolver returns &lt;code&gt;null&lt;/code&gt;. Whatever UI or report depended on them now shows blanks.&lt;/p&gt;

&lt;p&gt;The reason Shopify gave is reasonable (PCI / financial-data exposure narrowing). The reason apps break anyway is that nothing in the upgrade path forces you to notice. You bump your API version header from &lt;code&gt;2024-10&lt;/code&gt; to &lt;code&gt;2025-01&lt;/code&gt;, you ship, and the fields that used to come back populated just come back &lt;code&gt;null&lt;/code&gt;. Everything else is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CI Tests and Type Systems Don't Catch This
&lt;/h2&gt;

&lt;p&gt;Every time I write up one of these stories — the &lt;a href="https://dev.to/flarecanary/github-pushevent-silently-stopped-returning-commits-heres-what-we-learned-44cb"&gt;GitHub PushEvent commits field&lt;/a&gt;, Stripe's &lt;a href="https://dev.to/flarecanary/stripe-basil-current-period-end-undefined-2fjm"&gt;Basil &lt;code&gt;current_period_end&lt;/code&gt; move&lt;/a&gt;, now Shopify 2025-01 — the pattern is the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The upstream API version is under the upstream's control, not yours.&lt;/li&gt;
&lt;li&gt;The breaking change is a field removal or a type reshape, not a new 4xx status.&lt;/li&gt;
&lt;li&gt;Responses still parse. HTTP still returns success.&lt;/li&gt;
&lt;li&gt;Your unit tests pass because you mocked the response shape yourself.&lt;/li&gt;
&lt;li&gt;Your integration tests pass because your sandbox data was seeded before the change.&lt;/li&gt;
&lt;li&gt;Your type checker passes because your types were generated against the old schema.&lt;/li&gt;
&lt;li&gt;The break shows up in production, against real data, after a deploy that looked clean.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no test you can write inside your codebase that catches a response-shape change introduced by code you don't own. The contract is between two systems and you only control one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Catches It
&lt;/h2&gt;

&lt;p&gt;Three things can catch a silent upstream schema change, in order of cost:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Version pinning with forced upgrade cadence.&lt;/strong&gt; Shopify lets you pin your API version via the &lt;code&gt;Shopify-Api-Version&lt;/code&gt; header. Pin an explicit version and don't let it float. Then schedule the upgrade (2024-10 → 2025-01, etc.) as a deliberate project with a review of the full release notes, a grep of your codebase for removed/renamed fields, and a regression test against a non-production shop. This is what every Shopify app developer should already be doing. Most aren't, which is why forum posts like &lt;a href="https://community.shopify.dev/t/submit-for-review-button-is-disabled-due-to-deprecated-api-warning-after-upgrading-to-2025-04/32546" rel="noopener noreferrer"&gt;"Submit for review button is disabled after upgrading to 2025-04"&lt;/a&gt; keep showing up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Deprecation-warning monitoring.&lt;/strong&gt; Shopify exposes deprecated API call counts in the Partner Dashboard and will block app submissions if you have any in the 3-day window before review. Read the warnings, fix the calls. This requires that you bothered to upgrade in the first place, though — it doesn't catch drift on a version you've been sitting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Runtime shape monitoring.&lt;/strong&gt; Poll the endpoints your app actually hits on a schedule, record the response shape, and alert when a field disappears, a type shifts, or nullability changes. This is the one catch for APIs that change shape out from under you without you bumping any versions — and it's the one that works for every third-party API, not just the ones that publish a deprecation dashboard. (&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; does this; so do a few others. The point is to have &lt;em&gt;something&lt;/em&gt; doing it, not specifically us.)&lt;/p&gt;

&lt;p&gt;The economics matter. The cost of an undetected Shopify field rename isn't "an exception in the logs." It's usually something worse — wrong data on a merchant-facing dashboard, a fulfillment routing rule that silently misfires, a compliance report with a missing column. The business cost of one of those is larger than the annual cost of any monitor. And it only takes one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Next Shopify Release Is Already Doing This Again
&lt;/h2&gt;

&lt;p&gt;Shopify's 2026-07 release (still months away as of this writing) is dropping &lt;code&gt;grams&lt;/code&gt; from &lt;code&gt;DraftOrderLineItem&lt;/code&gt; in favor of &lt;code&gt;weight&lt;/code&gt;. &lt;code&gt;customerPaymentMethodRemoteCreditCardCreate&lt;/code&gt; is fully removed after January 2026. These are the ones listed in the changelog; history says there will be 5–10 more smaller ones by the time the version actually ships.&lt;/p&gt;

&lt;p&gt;If you integrate with Shopify, now is a good time to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grep your codebase for &lt;code&gt;heldBy&lt;/code&gt; (without the &lt;code&gt;App&lt;/code&gt;), &lt;code&gt;privateMetafield&lt;/code&gt;, &lt;code&gt;accountNumber&lt;/code&gt;, &lt;code&gt;routingNumber&lt;/code&gt;, &lt;code&gt;grams&lt;/code&gt;, &lt;code&gt;multiLocation&lt;/code&gt;, &lt;code&gt;customerPaymentMethodRemoteCreditCardCreate&lt;/code&gt;, &lt;code&gt;metafieldDelete&lt;/code&gt;, &lt;code&gt;visibleToStorefrontApi&lt;/code&gt;. Anything that comes back is a candidate break.&lt;/li&gt;
&lt;li&gt;Pin your &lt;code&gt;Shopify-Api-Version&lt;/code&gt; header explicitly if you aren't already.&lt;/li&gt;
&lt;li&gt;Subscribe to the &lt;a href="https://shopify.dev/changelog" rel="noopener noreferrer"&gt;Shopify changelog&lt;/a&gt; and actually read it.&lt;/li&gt;
&lt;li&gt;Set up shape monitoring on your most critical queries — fulfillment, orders, metafields, payments. Whether you do it yourself, use an observability tool, or use a schema-drift monitor, the cost of not doing it compounds every quarter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upstream API breaking changes are not a problem you can solve once. They're a recurring cost of running a product that depends on APIs you don't own. The apps that survive long-term aren't the ones that never get hit — they're the ones that notice within minutes instead of weeks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;Wilson&lt;/a&gt; — I write about how third-party API schemas break, quietly, against apps that depend on them. If you integrate with Shopify, Stripe, GitHub, Twilio, or any of a dozen others, &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; polls your critical endpoints and alerts on shape changes before your customers notice. Free tier. No card required.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>api</category>
      <category>graphql</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Notion's 2026-04-01 API changed pagination cursors and the RateLimit-Reset format — here's what silently breaks</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 29 May 2026 05:00:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/notions-2026-04-01-api-changed-pagination-cursors-and-the-ratelimit-reset-format-heres-what-319p</link>
      <guid>https://dev.to/flarecanary/notions-2026-04-01-api-changed-pagination-cursors-and-the-ratelimit-reset-format-heres-what-319p</guid>
      <description>&lt;p&gt;Notion's &lt;strong&gt;2026-04-01&lt;/strong&gt; API version shipped two changes that don't look dangerous in a release note and are very dangerous in a long-running integration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pagination cursors became &lt;strong&gt;opaque base64 strings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The rate-limit reset value changed from a &lt;strong&gt;Unix timestamp&lt;/strong&gt; to a &lt;strong&gt;delta in seconds&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither produces a schema error. Neither shows up in a diff of your request/response types. Both break code that was correct yesterday, and both fail in the direction that's hardest to notice: silent state corruption and silent loss of backoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 1: pagination cursors are now opaque base64
&lt;/h2&gt;

&lt;p&gt;Historically, Notion &lt;code&gt;start_cursor&lt;/code&gt; values were UUIDs. As of 2026-04-01 they're opaque base64-encoded strings. The compatibility rule is asymmetric, and the asymmetry is the trap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Old-format cursors (UUIDs) still work on 2026-04-01.&lt;/strong&gt; Forward compatibility is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New-format (base64) cursors do &lt;em&gt;not&lt;/em&gt; work on older API versions.&lt;/strong&gt; A cursor minted by a 2026-04-01 caller is rejected (400 &lt;code&gt;validation_error&lt;/code&gt;) if it's replayed against, say, &lt;code&gt;2025-09-03&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you paginate in a single request loop on one pinned version, you'll never notice — you mint and consume the cursor in the same context. The failure shows up when a cursor &lt;strong&gt;outlives the request that created it&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checkpointed / resumable syncs.&lt;/strong&gt; A long workspace export persists &lt;code&gt;next_cursor&lt;/code&gt; to a database or queue so it can resume after a crash or across a job boundary. The job that wrote the cursor was on 2026-04-01; the worker that resumes is still pinned to an older version (different service, different deploy cadence). The resume call 400s. Depending on how your retry logic treats a 400, you either hard-fail the sync or — worse — treat "invalid cursor" as "end of results" and silently truncate the export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed-version fleets.&lt;/strong&gt; Ingestion service A is upgraded to 2026-04-01; reconciliation worker B still pins the old version. They share a cursor store. Every cursor A writes is poison to B. Nothing logs a version mismatch — it's just a 400 that looks like a transient Notion error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor caches keyed by query.&lt;/strong&gt; Some integrations cache "where did I leave off for this database" by query hash. After the partial upgrade, cached cursors are a coin flip on whether they parse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason this is easy to miss in review: the code that reads the cursor is unchanged and correct. The bug is the &lt;em&gt;combination&lt;/em&gt; of a persisted cursor and a version skew between writer and reader. There's no single line you can point at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not upgrade your &lt;code&gt;Notion-Version&lt;/code&gt; header mid-sync if you persist cursors. Drain in-flight syncs on the old version first, or reset sync state entirely on cutover.&lt;/li&gt;
&lt;li&gt;Treat cursors as version-scoped. If you persist a cursor, persist the &lt;code&gt;Notion-Version&lt;/code&gt; that minted it next to it, and refuse to replay a cursor under a different version — fail loudly with a clear message instead of letting Notion return an ambiguous 400.&lt;/li&gt;
&lt;li&gt;On a forced cursor invalidation, restart that pagination from the beginning. Make sure your code distinguishes "invalid cursor, restart" from "no more pages, done." Conflating the two silently truncates data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Change 2: RateLimit-Reset is now a seconds delta, not a Unix timestamp
&lt;/h2&gt;

&lt;p&gt;Before 2026-04-01, the reset value Notion returned was a Unix timestamp — the wall-clock time the token bucket refills. As of 2026-04-01 it's a &lt;strong&gt;delta in seconds&lt;/strong&gt; — how many seconds from now until refill.&lt;/p&gt;

&lt;p&gt;Almost every backoff helper written against the old behavior looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-ratelimit-reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;sleep_for&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reset&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;# was: timestamp - now = seconds to wait
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sleep_for&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;sleep_for&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that against the new format, where &lt;code&gt;reset&lt;/code&gt; is now &lt;code&gt;30&lt;/code&gt; (meaning "30 seconds"):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sleep_for = 30 - 1_775_000_000   ≈  -1.77e9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sleep_for&lt;/code&gt; is hugely negative. The &lt;code&gt;if sleep_for &amp;gt; 0&lt;/code&gt; guard fails, the sleep is skipped entirely, and the client immediately retries the request that was just rate-limited. You haven't slowed down — you've &lt;strong&gt;removed all backoff at the exact moment Notion told you to back off.&lt;/strong&gt; The result is a retry storm that escalates you from soft 429s into longer enforced cooldowns or integration-level throttling.&lt;/p&gt;

&lt;p&gt;The mirror-image bug is just as quiet: code that interprets the new small number as an absolute timestamp computes a "reset" in 1970 and concludes the bucket is already available — same effect, no wait.&lt;/p&gt;

&lt;p&gt;There is no exception, no log line, no failed assertion. Throughput even looks &lt;em&gt;better&lt;/em&gt; for a few minutes because you stopped sleeping. Then Notion clamps down and the integration's latency falls off a cliff with no obvious cause, because the cause was a header value that changed type, not shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mitigations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat the reset value as a duration, not a timestamp: &lt;code&gt;sleep_for = float(resp.headers["x-ratelimit-reset"])&lt;/code&gt;, clamped to a sane max, and honor &lt;code&gt;Retry-After&lt;/code&gt; when present.&lt;/li&gt;
&lt;li&gt;Add an upper bound and a lower bound to any computed sleep. A correct backoff should never compute a negative wait or a multi-year wait; if it does, that's a format-mismatch signal — alert on it instead of clamping silently.&lt;/li&gt;
&lt;li&gt;If you can, pin and assert: log the raw header on the first rate-limited response after a version bump and eyeball whether it's ~10^9 (timestamp) or ~10^1–10^2 (delta).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why these two belong in the same checklist
&lt;/h2&gt;

&lt;p&gt;Both changes share a signature we see constantly on review: &lt;strong&gt;the response is structurally identical, but a value's encoding or meaning changed.&lt;/strong&gt; Your types still compile. Your mocks still pass — a mock returns whatever cursor or reset value you hard-coded, so the test never sees the new format. Your happy-path manual test passes, because it runs on one version in one process and never persists a cursor or hits a 429.&lt;/p&gt;

&lt;p&gt;The breakage only appears with real state and real load: a cursor that crosses a version boundary, or a rate-limit response under contention. That's production, not CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Audit every place a Notion cursor is persisted (DB, queue, cache, checkpoint file). For each, store the minting &lt;code&gt;Notion-Version&lt;/code&gt; alongside it and reject cross-version replay explicitly.&lt;/li&gt;
&lt;li&gt;Upgrade all services that share a cursor store in the same change, or don't share the store across versions. A partial fleet upgrade is the failure mode.&lt;/li&gt;
&lt;li&gt;Ensure your pagination loop distinguishes "invalid/expired cursor → restart" from "no next_cursor → done." Never let a 400 silently end a sync.&lt;/li&gt;
&lt;li&gt;Change rate-limit backoff to treat the reset value as a seconds delta. Add min/max bounds and alert (don't clamp) when a computed sleep is negative or absurdly large.&lt;/li&gt;
&lt;li&gt;Add an integration test that exercises a real (small) paginated query and a forced 429 against the live API on 2026-04-01 — not a mock — before you flip the version in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both of these are date-versioned, so nothing breaks until you bump &lt;code&gt;Notion-Version&lt;/code&gt;. That's also the trap: the bump is a one-line change that passes review, passes CI, and detonates later in a resume path or under load, far from the diff that caused it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like these — including value-encoding changes that keep the schema identical while silently corrupting state — and surfaces them before they hit production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

&lt;p&gt;The app queries discounts on its pinned version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="c"&gt;# App pinned to API version 2026-01&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;discountNodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DiscountCodeBasic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DiscountAutomaticBasic&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merchant has 12 active discounts. Three of them are scoped to the "North America" market. The query returns &lt;strong&gt;9 nodes&lt;/strong&gt;, HTTP 200, no &lt;code&gt;userErrors&lt;/code&gt;, no extensions warning. Nothing in the response indicates three discounts were withheld.&lt;/p&gt;

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

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

&lt;p&gt;This is the dangerous one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;discountNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/DiscountAutomaticNode/1099876"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If discount &lt;code&gt;1099876&lt;/code&gt; has market eligibility set and you're on a pre-2026-07 version, this returns &lt;code&gt;null&lt;/code&gt;. Same as a discount that was deleted. Same as an ID that never existed.&lt;/p&gt;

&lt;p&gt;A lot of integration code treats "I asked for this ID and got null" as a tombstone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shopify_gid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;local_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# discount removed upstream — clean up
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The reason this one is worth acting on now rather than at a cutoff date: there is no cutoff. The filter is active the moment a merchant uses a feature Shopify has already shipped to every Basic-and-up store. The gap between "discount visible in admin" and "discount absent from API" opens silently, on the merchant's schedule, not yours — and the only signal is a row count that looks plausible.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like this one — including version-gated response filtering that returns a clean 200 with rows quietly removed — and surfaces them before they hit production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;A common test pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic.Anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_summarizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mock_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mock_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The fact that the API format is unchanged is what makes this dangerous. There is no schema diff to spot at code-review time. The only signal you'll get is the request failing in production at 12:00 UTC on June 15 — unless you go looking for the alias now.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like this one — including model retirements and alias remappings — and surfaces them before they hit production. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;The reason this one is worth a sprint and not a backlog ticket: every other model retirement this year threw an error eventually. This one is engineered specifically &lt;em&gt;not&lt;/em&gt; to. "Your code keeps working" is the failure mode, not the mitigation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; watches your third-party APIs and SDKs for breaking changes like this one — including model retirements, silent slug redirects, and pricing-tier remaps — and surfaces them before the invoice does. Free tier monitors 5 endpoints.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

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

&lt;p&gt;Legacy response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Why did the chicken cross the road?"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model_output"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Why did the chicken cross the road?"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape change cascades:&lt;/p&gt;

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

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

&lt;p&gt;Legacy request for JSON output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this article."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_mime_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this article."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mime_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;response_mime_type&lt;/code&gt; at the top level is gone — it folds into &lt;code&gt;response_format.mime_type&lt;/code&gt;. The schema moves into &lt;code&gt;response_format.schema&lt;/code&gt;. The discriminator that picks text vs image vs audio is &lt;code&gt;response_format.type&lt;/code&gt;. After May 26, the server silently ignores the legacy top-level &lt;code&gt;response_mime_type&lt;/code&gt; field; your JSON-mode call quietly stops being JSON-mode.&lt;/p&gt;

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

&lt;p&gt;Legacy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate an image of a sunset over the ocean."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"generation_config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image_config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aspect_ratio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1:1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"image_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1K"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3-flash-preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate an image of a sunset over the ocean."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mime_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/jpeg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aspect_ratio"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1:1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1K"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields are gone from &lt;code&gt;generation_config&lt;/code&gt;. Server-side they're not read from that location anymore. The image still generates — just at whatever the new default aspect ratio and size are. Your "always render 1:1 thumbnails" job starts shipping 16:9 widescreens with no log line to explain it.&lt;/p&gt;

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

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

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

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

&lt;p&gt;Legacy tool-call response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"requires_action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function_call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fc_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Boston, MA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"int_001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"requires_action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"function_call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fc_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"get_weather"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Boston, MA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tool dispatch loops typically iterate &lt;code&gt;outputs&lt;/code&gt;, find &lt;code&gt;type === "function_call"&lt;/code&gt; entries, invoke the handler, then submit results back. After the flip, &lt;code&gt;outputs&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, so the loop iterates nothing, finds no function calls, returns the unaltered &lt;code&gt;requires_action&lt;/code&gt; interaction to the caller, and the agent stalls. No exception — just an agent that "didn't decide to call a tool this turn." The same trap applies to &lt;code&gt;google_search_call&lt;/code&gt;, &lt;code&gt;google_search_result&lt;/code&gt;, and &lt;code&gt;thought&lt;/code&gt; step types, all of which now live under &lt;code&gt;steps&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Legacy &lt;code&gt;thought&lt;/code&gt; was minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thought"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New &lt;code&gt;thought&lt;/code&gt; carries a structured summary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thought"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I need to check the weather in Boston..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any logging or audit code reading &lt;code&gt;thought.text&lt;/code&gt; (which never existed but was a common guess) silently gets &lt;code&gt;undefined&lt;/code&gt;. Code that round-trips &lt;code&gt;thought&lt;/code&gt; back to Gemini for stateless continuation now has to preserve the &lt;code&gt;summary&lt;/code&gt; array — drop it and the model loses its scratchpad.&lt;/p&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Once a paginated query would cross the 10,000-result boundary, Notion stops the cursor walk and returns a response shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;within&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="err"&gt;k&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;window...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"next_cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has_more"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"incomplete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"incomplete_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"query_result_limit_reached"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tell is &lt;code&gt;request_status&lt;/code&gt;. On a normal, fully-paginated response it's either absent or &lt;code&gt;"type": "complete"&lt;/code&gt;. On a truncated one it's &lt;code&gt;"type": "incomplete"&lt;/code&gt; with &lt;code&gt;incomplete_reason: "query_result_limit_reached"&lt;/code&gt;. Notice what &lt;em&gt;isn't&lt;/em&gt; different: &lt;code&gt;has_more&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; (just like a real end-of-results), &lt;code&gt;next_cursor&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; (just like a real end-of-results), the HTTP status is &lt;code&gt;200&lt;/code&gt;, and the &lt;code&gt;results&lt;/code&gt; array is a perfectly valid array of perfectly valid pages. Nothing about the response trips an exception, a schema validator, or an HTTP-status check.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

&lt;p&gt;&lt;strong&gt;1. Check &lt;code&gt;request_status&lt;/code&gt; on every paginated response.&lt;/strong&gt; This is the actual fix. Anywhere you loop on &lt;code&gt;has_more&lt;/code&gt;, also look at &lt;code&gt;request_status&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFullPage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@notionhq/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NOTION_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queryAllRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;notion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data_source_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dataSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;start_cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_more&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next_cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Throwing is the right default for a sync job: a loud failure you can see beats a quiet truncation you can't. If you'd rather degrade gracefully, at minimum increment a metric and log a warning with the row count — don't let &lt;code&gt;incomplete&lt;/code&gt; pass unobserved.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;201&lt;/span&gt; &lt;span class="ne"&gt;Created&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_refresh_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rest projects.read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After May 26:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sbp_refresh_v0_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rest projects.read"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The body doesn't move. No headers change. The rationale Supabase gives is direct: &lt;a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1" rel="noopener noreferrer"&gt;OAuth 2.1 Section 3.2.3&lt;/a&gt; mandates &lt;code&gt;200&lt;/code&gt; from token endpoints, and "Returning &lt;code&gt;201&lt;/code&gt; is non-compliant and has caused token exchange failures with some strict OAuth clients."&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;&lt;strong&gt;Surface 2: Test assertions silently flip green-to-red — but only on CI, not locally.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_oauth_token_exchange&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oauth_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchange_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;test_auth_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;-- the bomb
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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




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

</description>
      <category>supabase</category>
      <category>oauth</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
  </channel>
</rss>
