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

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

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



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

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

&lt;span class="c1"&gt;# 3. Length assertion in middleware.
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expected 40-char token, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Fixed-size buffer in C/Go/Rust FFI.
&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt; &lt;span class="n"&gt;token_buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;  &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;overflows&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="n"&gt;memcpy&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;d, or strncpy truncates
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;Search every repo that touches a GitHub App for the four patterns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. DB column types&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"token.*VARCHAR&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;([0-9]+)&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.sql'&lt;/span&gt; &lt;span class="s1"&gt;'*.ts'&lt;/span&gt; &lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="s1"&gt;'*.go'&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"varchar&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;|VARCHAR&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;|String&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;40&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Don't anchor on the new max length (~520) either; that's a current ceiling that GitHub may move again later. Anchor only on the prefix and character set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Wrong — pins to current new ceiling
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^ghs_[A-Za-z0-9]{36,520}$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Right — accepts anything matching prefix and charset
&lt;/span&gt;&lt;span class="n"&gt;TOKEN_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^ghs_[A-Za-z0-9]+$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you actually need a length constraint for a defensive reason (e.g., a sanity check before storing), set a generous upper bound — 4096 is a reasonable belt-and-suspenders ceiling that won't bind on a future format change.&lt;/p&gt;

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

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

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

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

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

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

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

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




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

</description>
      <category>github</category>
      <category>api</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Cloudflare is removing in-place DNS record type changes on June 30, 2026 — your Pulumi runs will start failing</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 06 May 2026 04:01:54 +0000</pubDate>
      <link>https://dev.to/flarecanary/cloudflare-is-removing-in-place-dns-record-type-changes-on-june-30-2026-your-pulumi-runs-will-2j2k</link>
      <guid>https://dev.to/flarecanary/cloudflare-is-removing-in-place-dns-record-type-changes-on-june-30-2026-your-pulumi-runs-will-2j2k</guid>
      <description>&lt;p&gt;Cloudflare announced on January 23, 2026 that &lt;strong&gt;changing the type of an existing DNS record via the API is deprecated&lt;/strong&gt;. End-of-life: &lt;strong&gt;June 30, 2026.&lt;/strong&gt; After that date, a PATCH or PUT against &lt;code&gt;/zones/{zone_id}/dns_records/{id}&lt;/code&gt; that flips a record's &lt;code&gt;type&lt;/code&gt; field — &lt;code&gt;A&lt;/code&gt; → &lt;code&gt;CNAME&lt;/code&gt;, &lt;code&gt;CNAME&lt;/code&gt; → &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;TXT&lt;/code&gt; → &lt;code&gt;MX&lt;/code&gt; — stops being supported.&lt;/p&gt;

&lt;p&gt;The deprecation page doesn't promise a specific error code, just that "attempts to change a record's type via update operations will no longer be supported." Read that as: today it works, July 1 it doesn't, and the failure shape (4xx? 200 with &lt;code&gt;success: false&lt;/code&gt;? silent partial?) won't be locked down until cutover.&lt;/p&gt;

&lt;p&gt;If you're in the 90% of teams using the official Cloudflare Terraform provider on a recent version, you're probably fine — the provider already tags &lt;code&gt;type&lt;/code&gt; as &lt;code&gt;RequiresReplace&lt;/code&gt;, so it does delete-then-create on the client side (&lt;a href="https://github.com/cloudflare/terraform-provider-cloudflare/issues/6358" rel="noopener noreferrer"&gt;cloudflare/terraform-provider-cloudflare#6358&lt;/a&gt;). The teams that get bit are the ones running anything else against &lt;code&gt;dns_records/{id}&lt;/code&gt; with a different type than what's in the record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the breakage actually lives
&lt;/h2&gt;

&lt;p&gt;Search your repos for direct DNS PATCH/PUT calls, not just Terraform configs:&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;-nE&lt;/span&gt; &lt;span class="s2"&gt;"dns_records/[A-Za-z0-9]+"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="s1"&gt;'*.ts'&lt;/span&gt; &lt;span class="s1"&gt;'*.js'&lt;/span&gt; &lt;span class="s1"&gt;'*.go'&lt;/span&gt; &lt;span class="s1"&gt;'*.rb'&lt;/span&gt; &lt;span class="s1"&gt;'*.sh'&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'PATCH|PUT'&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*.tf'&lt;/span&gt; &lt;span class="s1"&gt;'*.yaml'&lt;/span&gt; &lt;span class="s1"&gt;'*.yml'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; dns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common hiding spots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenTofu / older Cloudflare provider forks.&lt;/strong&gt; The &lt;code&gt;RequiresReplace&lt;/code&gt; modifier landed in the schema rewrite — if you're pinned to a pre-v5 provider, you're still hitting the in-place PATCH path under the hood.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pulumi &lt;code&gt;cloudflare.Record&lt;/code&gt;.&lt;/strong&gt; The Pulumi provider mirrors the upstream API's PATCH semantics for property updates. Confirm against your provider version that a &lt;code&gt;type&lt;/code&gt; change forces replacement, not update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDK / SDK direct calls.&lt;/strong&gt; Anything using &lt;code&gt;cloudflare-sdk&lt;/code&gt; or the raw &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;fetch&lt;/code&gt;-against-the-REST-API pattern, where someone wrote a "reconciler" that PATCHes whatever fields differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal admin tools.&lt;/strong&gt; That Slack bot you wrote to flip a hostname between origins. The internal "fix DNS" runbook in your wiki. The migration script your platform team kept after the last datacenter move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI jobs that re-apply DNS state.&lt;/strong&gt; GitHub Actions workflows that call &lt;code&gt;cf-cli set-record&lt;/code&gt; or equivalent and assume the API will figure out an in-place update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Cloudflare dashboard uses PATCH for type changes today, so anyone who reverse-engineered the dashboard's calls into a script (looking at you, "I just used the network tab") has a script that breaks on July 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just delete and create" is a footgun
&lt;/h2&gt;

&lt;p&gt;The naive migration path — delete the old record, create a new one with the new type — is correct for end-state but creates a non-zero NXDOMAIN window between the two API calls. Two consequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Real DNS resolvers in the path will cache the NXDOMAIN.&lt;/strong&gt; Most won't honor a TTL of zero on a negative answer. Mail servers in particular are aggressive negative cachers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent automation can race the gap.&lt;/strong&gt; If your reconciler runs every 60 seconds and the create call fails for any reason — auth, rate limit, validation — you've left the zone with a missing record, and the next reconciliation will create the &lt;em&gt;old&lt;/em&gt; type if the source-of-truth state hasn't propagated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Don't do this naively. Use the &lt;a href="https://blog.cloudflare.com/batched-dns-changes/" rel="noopener noreferrer"&gt;Batch DNS records API&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Batch DNS records API isn't a drop-in either
&lt;/h2&gt;

&lt;p&gt;POST to &lt;code&gt;/zones/{zone_id}/dns_records/batch&lt;/code&gt; with this shape:&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;"deletes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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;"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;"old-record-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="nl"&gt;"patches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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;"puts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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;"posts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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;"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;"api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"CNAME"&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="s2"&gt;"lb.example.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ttl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"proxied"&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="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 operations execute in a strict order: &lt;strong&gt;deletes → patches → puts → posts&lt;/strong&gt;. That ordering matters more than it looks like it does.&lt;/p&gt;

&lt;p&gt;Concrete example: you have an &lt;code&gt;A&lt;/code&gt; record at &lt;code&gt;api.example.com&lt;/code&gt; and want to flip it to &lt;code&gt;CNAME api.example.com → lb.example.net&lt;/code&gt;. RFC 1912 prohibits a CNAME and an A record coexisting on the same hostname. If you tried to put the create &lt;em&gt;first&lt;/em&gt;, Cloudflare would reject the new CNAME because the old A still exists. The fixed deletes-first ordering means batch requests for type changes always work, as long as you remember to put the old record in &lt;code&gt;deletes&lt;/code&gt; and the new record in &lt;code&gt;posts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The batch is atomic at the database level — if any single operation fails, the whole transaction rolls back and you get the first error. &lt;strong&gt;It is not atomic at the edge.&lt;/strong&gt; Cloudflare's blog explicitly calls this out: changes propagate through Quicksilver as independent key-value pairs, so resolvers may briefly see intermediate states during propagation. The window is short (low single-digit seconds), but it exists, and it's worse for high-volume zones because Quicksilver can serialize updates differently across colos.&lt;/p&gt;

&lt;p&gt;If you have an SLA tighter than a few seconds of resolver inconsistency on the affected hostname, batch isn't enough either. You need the new hostname behind the new record on a &lt;em&gt;different&lt;/em&gt; name during cutover, then swap upstream config to point at the new name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limits will surprise the largest scripts
&lt;/h2&gt;

&lt;p&gt;The batch limits are different from the per-record API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free plan:&lt;/strong&gt; 200 operations per batch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid plans:&lt;/strong&gt; 3,500 operations per batch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a migration script that walks 5,000 records and converts CNAMEs to A records (or vice-versa) and you're on the Free plan, it now needs to chunk by 200 with backoff. Cloudflare has tested batches up to 100,000 operations on enterprise tiers, so the ceiling exists, but it's tier-gated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the silent version of this failure looks like
&lt;/h2&gt;

&lt;p&gt;The loud failure — Cloudflare returns 4xx after July 1 — is the easy case. Build red, fix forward.&lt;/p&gt;

&lt;p&gt;The quieter failure is the migration &lt;em&gt;to&lt;/em&gt; batch that subtly breaks something else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-order operations in the same batch.&lt;/strong&gt; If you submit &lt;code&gt;deletes: [A record]&lt;/code&gt; and &lt;code&gt;posts: [A record with new type]&lt;/code&gt; to the &lt;em&gt;same hostname&lt;/em&gt;, you're fine — order is fixed. But if you submit &lt;code&gt;puts&lt;/code&gt; and &lt;code&gt;posts&lt;/code&gt; on overlapping hostnames in one batch and the put fixes a different field, you can end up with surprising state because puts happen before posts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost-write between source-of-truth and Cloudflare during the gap.&lt;/strong&gt; Your reconciler runs at T=0, sees an A record, writes "I want CNAME" to source-of-truth at T=1, the batch fires at T=2, but a competing operator changed the source-of-truth to "I want TXT" at T=1.5. The batch happily deletes the A and creates a CNAME that's already stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge cache poisoning during cutover.&lt;/strong&gt; A high-traffic resolver hits the hostname during the propagation window, gets the &lt;em&gt;old&lt;/em&gt; type, and caches it for full TTL. Subsequent queries from that resolver see a stale answer until the cache expires. SaaS API consumers caching DNS in-process (looking at you, JVM and Go's pre-1.19 default resolver) will see this for the full TTL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These all &lt;em&gt;work&lt;/em&gt; on a single integration test — the record is at the new type, GET returns the new shape — and only break under concurrent load or specific resolver paths.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git grep&lt;/code&gt; every call site that PATCHes or PUTs &lt;code&gt;dns_records/{id}&lt;/code&gt; and inventory which ones can change &lt;code&gt;type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bump the Cloudflare Terraform provider to a version with the schema rewrite (&lt;code&gt;type&lt;/code&gt; tagged &lt;code&gt;RequiresReplace&lt;/code&gt;). Do this in a non-prod zone first — you'll see a destroy-and-create plan for any record where TF state and Cloudflare diverge on type.&lt;/li&gt;
&lt;li&gt;For non-Terraform paths, switch to &lt;code&gt;POST /zones/{zone_id}/dns_records/batch&lt;/code&gt; with the old record in &lt;code&gt;deletes&lt;/code&gt; and the new in &lt;code&gt;posts&lt;/code&gt;. Wrap it so callers can't accidentally submit the same hostname in &lt;code&gt;posts&lt;/code&gt; and &lt;code&gt;patches&lt;/code&gt; simultaneously.&lt;/li&gt;
&lt;li&gt;Add a validation step in your reconciler: if &lt;code&gt;desired.type != current.type&lt;/code&gt;, route the change through batch, never through direct PATCH/PUT — even before the cutover.&lt;/li&gt;
&lt;li&gt;Lower TTLs on records you expect to flip type on, &lt;em&gt;before&lt;/em&gt; cutover, so the propagation window shrinks. 60-300s during migrations, restore after.&lt;/li&gt;
&lt;li&gt;Subscribe to Cloudflare's API deprecations RSS or page-monitor &lt;a href="https://developers.cloudflare.com/fundamentals/api/reference/deprecations/" rel="noopener noreferrer"&gt;the deprecations page&lt;/a&gt;. Two more deprecations are likely to land before EOY.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Cloudflare's deprecation page is short and easy to miss. The change feels like a small migration ("just use batch"), and the batch API even existed for years before this announcement. But the failure surface is asymmetric — Terraform users barely notice, while anyone running a reconciler or admin tool against the legacy update endpoint will quietly start failing on July 1.&lt;/p&gt;

&lt;p&gt;API contracts include the &lt;em&gt;legal sequences of operations&lt;/em&gt; on a resource, not just the request/response shapes. "Update record type from A to CNAME" was a legal sequence today and isn't legal in two months. No SDK type system, schema diff, or contract test catches that, because the request shape is unchanged. The only signal is the deprecation note, and it gets read by the people writing IaC modules — not the people who wrote the admin tool three years ago and moved teams.&lt;/p&gt;




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

</description>
      <category>cloudflare</category>
      <category>dns</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stripe Basil Quietly Moved current_period_end Off Subscription — And a Lot of Code Broke</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 05 May 2026 13:02:23 +0000</pubDate>
      <link>https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-3eo7</link>
      <guid>https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-3eo7</guid>
      <description>&lt;p&gt;On March 31, 2025, Stripe shipped the Basil API version. Among other changes, it removed three fields from the &lt;code&gt;Subscription&lt;/code&gt; object that a lot of production code was reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;current_period_start&lt;/code&gt; — &lt;strong&gt;moved to subscription items&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current_period_end&lt;/code&gt; — &lt;strong&gt;moved to subscription items&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;billing_thresholds&lt;/code&gt; — &lt;strong&gt;removed entirely&lt;/strong&gt; (later reintroduced — more on this)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you upgraded your account's default API version without pinning the SDK, the endpoint still returned &lt;code&gt;200 OK&lt;/code&gt;. The subscription objects still serialized cleanly. The fields your code accessed just came back &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One of those fields is &lt;code&gt;current_period_end&lt;/code&gt;. If your app uses Stripe subscriptions at all, there's a very good chance you read &lt;code&gt;current_period_end&lt;/code&gt; somewhere. Maybe it populates the "next bill date" in your UI. Maybe it drives a cron job that reminds customers before renewal. Maybe it gates feature access for annual plans. Whatever it is, it quietly stopped working.&lt;/p&gt;

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

&lt;p&gt;From the &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end" rel="noopener noreferrer"&gt;Basil changelog&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;current_period_start&lt;/code&gt; and &lt;code&gt;current_period_end&lt;/code&gt; are removed from subscriptions. Instead, access the subscription item's billing periods directly via &lt;code&gt;items.data[].current_period_start&lt;/code&gt; and &lt;code&gt;items.data[].current_period_end&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rationale is sound. In the old model, every subscription had a single billing cycle. In the new flexible-billing model, each &lt;em&gt;item&lt;/em&gt; in a subscription can have its own cycle — useful for mixing monthly and annual items, or metered and flat items, on one subscription. Moving the fields down to the item level reflects reality.&lt;/p&gt;

&lt;p&gt;From the consumer side, though, the surface looked like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (2025-02-24.acacia):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"subscription"&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;"sub_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1712678400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&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;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (2025-03-31.basil):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"subscription"&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;"sub_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"current_period_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"current_period_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1712678400&lt;/span&gt;&lt;span class="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;Still a valid &lt;code&gt;Subscription&lt;/code&gt; object. Still everything serializes. Just no more top-level period fields.&lt;/p&gt;

&lt;p&gt;The breakage affected &lt;strong&gt;every SDK language&lt;/strong&gt; — Node, Python, PHP, Java, Go, .NET. (Ruby, per the changelog, was the only one unaffected by this particular change, because of how that SDK maps the object.)&lt;/p&gt;

&lt;h2&gt;
  
  
  billing_thresholds — Removed, Then Quietly Un-Removed
&lt;/h2&gt;

&lt;p&gt;The other Basil change that bit a lot of teams was &lt;code&gt;billing_thresholds&lt;/code&gt; disappearing. This one has an even weirder story.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;billing_thresholds&lt;/code&gt; was how you told Stripe "automatically invoice this subscription when the customer's usage passes this amount." For metered billing at scale, it was the thing that prevented a single runaway customer from accumulating a six-figure bill you might never collect.&lt;/p&gt;

&lt;p&gt;On 2025-03-31, Stripe removed it from the Subscription API. The &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-legacy-usage-based-billing" rel="noopener noreferrer"&gt;initial migration advice&lt;/a&gt; pointed teams at "metered billing alerts" as a replacement.&lt;/p&gt;

&lt;p&gt;Developers quickly noticed the replacement wasn't on par. From &lt;a href="https://github.com/stripe/stripe-node/issues/2328" rel="noopener noreferrer"&gt;stripe-node issue #2328&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's not clear why it was removed before the metered version was fully ready."&lt;/p&gt;

&lt;p&gt;"The dashboard still allows setting billing thresholds and even generates the curl command. But when you actually use the same curl request, it produces an error stating the parameter is no longer supported."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Metered billing alerts only fired once per customer lifetime and didn't carry subscription IDs. You could not use them to auto-invoice at a threshold. There was no direct replacement for the thing that had been removed.&lt;/p&gt;

&lt;p&gt;On 2025-05-28, Stripe &lt;a href="https://docs.stripe.com/changelog/basil/2025-05-28/reintroduce-billing-thresholds" rel="noopener noreferrer"&gt;reintroduced billing_thresholds&lt;/a&gt;. The field came back. Anyone who had already rewritten their billing logic to work around its absence had just shipped two migrations to land in the same place.&lt;/p&gt;

&lt;p&gt;This is the &lt;em&gt;other&lt;/em&gt; failure mode of silent schema changes: the churn of reacting to a change, then reacting to its reversal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most Teams Didn't Catch the Migration
&lt;/h2&gt;

&lt;p&gt;The cleanest version of this upgrade is: pin your SDK to the old Basil-adjacent version, upgrade the SDK deliberately, test against a staging Stripe account, ship. A lot of teams do this. A lot don't.&lt;/p&gt;

&lt;p&gt;Here are the common ways it slipped through:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Library auto-upgrades.&lt;/strong&gt; A Dependabot PR bumps your &lt;code&gt;stripe-node&lt;/code&gt; minor version, tests pass (because fixtures are from before the migration), and you merge it. Your SDK is now Basil-aware, your code still reads &lt;code&gt;subscription.current_period_end&lt;/code&gt;, and the field is &lt;code&gt;undefined&lt;/code&gt; in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-level API version default.&lt;/strong&gt; Stripe lets you upgrade your account's default version in the dashboard. If someone on your team clicks through the upgrade flow without coordinating with engineering, every non-pinned API call starts returning the new shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook events.&lt;/strong&gt; Webhook payloads use the API version in effect at the time the event is created. If your account default shifts, your webhook handlers start receiving new-shape &lt;code&gt;invoice.*&lt;/code&gt; and &lt;code&gt;customer.subscription.*&lt;/code&gt; events — often before your SDK has been upgraded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript didn't save anyone.&lt;/strong&gt; The Stripe Node SDK updates its types with each version. If you pin to an older version, your types still describe &lt;code&gt;current_period_end&lt;/code&gt; as top-level and your code happily accesses it. The runtime object just doesn't have it. TypeScript can't catch that — types are a compile-time shape, not a runtime guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests that mock Stripe.&lt;/strong&gt; If your tests use recorded fixtures or a mock Stripe library, they validate against yesterday's shape, not today's live API. Your CI stays green while prod returns &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same pattern as every other silent schema change: the thing that's supposed to catch it was designed against the shape that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke In Practice
&lt;/h2&gt;

&lt;p&gt;A few of the concrete failure modes I've seen or heard about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dunning emails stopped.&lt;/strong&gt; The cron that emails customers "your subscription renews on X" read &lt;code&gt;subscription.current_period_end&lt;/code&gt;, got &lt;code&gt;undefined&lt;/code&gt;, and sent "your subscription renews on &lt;strong&gt;Invalid Date&lt;/strong&gt;" — or skipped the send entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer-facing dashboards went blank.&lt;/strong&gt; "Next invoice" widgets showed empty cells for every new subscription created after the cutover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proration math got weird.&lt;/strong&gt; Code that calculated time remaining in the current period used &lt;code&gt;current_period_end - Date.now()&lt;/code&gt;; when the field was undefined, proration defaulted to zero or to a NaN that cascaded into charge amounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting numbers drifted.&lt;/strong&gt; Analytics jobs that grouped subscriptions by current-period end-date started dropping rows because the field was missing from the row entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are showy failures. No 500s, no exceptions in Sentry. Just wrong or missing data on the customer experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration That Actually Works
&lt;/h2&gt;

&lt;p&gt;For the &lt;code&gt;current_period_end&lt;/code&gt; move specifically, the honest migration is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin your SDK&lt;/strong&gt; to the version you've tested. Don't let Dependabot drive your billing API upgrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read periods from items, not the subscription.&lt;/strong&gt; For single-item subscriptions, &lt;code&gt;subscription.items.data[0].current_period_end&lt;/code&gt; is the direct replacement. For multi-item, decide what "period end" means for your use case — the earliest item? The one that matches a specific price? Your code now has to answer that question explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update webhook handlers.&lt;/strong&gt; &lt;code&gt;customer.subscription.updated&lt;/code&gt;, &lt;code&gt;invoice.created&lt;/code&gt;, and related events use the account API version. Test them against the new shape before flipping your account default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage the upgrade behind a feature flag&lt;/strong&gt; if you can. Flip it on in staging, compare prod vs staging subscription reads for a week, then promote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;code&gt;billing_thresholds&lt;/code&gt;, the migration depends on when you started. If you started before 2025-05-28, you might have rewritten on top of metered alerts. Consider whether to revert to &lt;code&gt;billing_thresholds&lt;/code&gt; now that it's back — or stay on whatever you built, since the reintroduction might not be permanent either.&lt;/p&gt;

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

&lt;p&gt;This is the part that generalizes beyond Stripe. Pinning versions works for APIs that version properly. It does not help for the cases where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API version didn't change but the response shape did (GitHub Events API, some OpenAI endpoints)&lt;/li&gt;
&lt;li&gt;You're on an account-default version and someone upstream flipped it&lt;/li&gt;
&lt;li&gt;The SDK was upgraded but the API default was left alone, so your types and runtime disagree&lt;/li&gt;
&lt;li&gt;The change is "allowed" within the version contract (e.g., a new optional field that turns out to be required when you want to match the old UX)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general defense is to watch the shape of the responses you depend on. That can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cron-scheduled script that hits your top N endpoints, hashes the field set, and alerts when the hash changes.&lt;/li&gt;
&lt;li&gt;A test that runs nightly against live endpoints (not fixtures) and asserts on the structure of the response.&lt;/li&gt;
&lt;li&gt;A purpose-built schema drift monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've been building &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for the third option. Point it at the API endpoints you depend on — Stripe, Plaid, OpenAI, Salesforce, whatever — and it polls them on a schedule, learns the response structure, and alerts when a field disappears, a type shifts, or a new field appears. Severity-classified: removed fields are alerts, new optional fields are informational, nullability flips fall somewhere in between.&lt;/p&gt;

&lt;p&gt;You do not strictly need a tool for this. You need &lt;em&gt;a habit&lt;/em&gt;. The Basil migration is a specific case of a very general problem: the APIs you depend on are changing in ways your type system and your CI can't see. The only reliable signal is watching the live response shape and comparing it to yesterday's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Thing That Actually Matters
&lt;/h2&gt;

&lt;p&gt;Stripe did a fine job of the Basil release from the provider side. The changelog was clear. The migration docs existed. The new model is better designed than the old one. The &lt;code&gt;billing_thresholds&lt;/code&gt; reintroduction, awkward as it was, was the right call once the replacement turned out to be insufficient.&lt;/p&gt;

&lt;p&gt;None of that changed the fact that some number of teams woke up one Monday with blank "next invoice" dates in their dashboards because nobody owned "does the shape of the Subscription object match what our code expects?" as a question worth asking before every API version bump.&lt;/p&gt;

&lt;p&gt;That's the monitoring gap. HTTP status codes tell you an endpoint is up. Response latencies tell you it's fast. Neither of those tells you that &lt;code&gt;current_period_end&lt;/code&gt; moved three levels deeper into the response tree.&lt;/p&gt;

&lt;p&gt;If you're on Stripe and haven't explicitly upgraded to Basil yet, this is your warning. If you already upgraded and everything's green, run a grep for &lt;code&gt;current_period_end&lt;/code&gt; across your codebase and see what comes back. It is a very common field, and the number of surprising places it shows up tends to be higher than people expect.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've been bit by the Basil migration — or by any silent schema change on another API — I'd like to hear about it. The "undefined field, no error" failures are the ones I'm most interested in. Drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>api</category>
      <category>billing</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>shopify app deploy --force is going away in May 2026 — your CI/CD is the problem</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 05 May 2026 04:03:47 +0000</pubDate>
      <link>https://dev.to/flarecanary/shopify-app-deploy-force-is-going-away-in-may-2026-your-cicd-is-the-problem-2ke3</link>
      <guid>https://dev.to/flarecanary/shopify-app-deploy-force-is-going-away-in-may-2026-your-cicd-is-the-problem-2ke3</guid>
      <description>&lt;p&gt;Shopify is removing the &lt;code&gt;--force&lt;/code&gt; flag from &lt;code&gt;shopify app deploy&lt;/code&gt; and &lt;code&gt;shopify app release&lt;/code&gt; in a &lt;strong&gt;May 2026 CLI release&lt;/strong&gt;. If your CI/CD pipeline calls either command with &lt;code&gt;--force&lt;/code&gt; today, the first pipeline run after your next &lt;code&gt;@shopify/cli&lt;/code&gt; bump will fail with "unknown flag."&lt;/p&gt;

&lt;p&gt;The replacement flags exist now. They don't mean the same thing &lt;code&gt;--force&lt;/code&gt; did. Swapping blindly either breaks deploys or — worse — silently starts deleting shop data that previously required confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the failure looks like
&lt;/h2&gt;

&lt;p&gt;CLI drops an unknown flag with a non-zero exit and a message like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ›   Error: Nonexistent flag: --force
 ›   See more help with --help
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which then cascades through whatever GitHub Actions / GitLab CI / CircleCI job wraps the command. Most pipelines surface this as a red build; some surface it as a silent skip if the step uses &lt;code&gt;continue-on-error&lt;/code&gt;. Either way, no new app version ships until someone touches the pipeline.&lt;/p&gt;

&lt;p&gt;The trigger isn't a calendar event — it's the CLI bump. If your pipeline pins &lt;code&gt;@shopify/cli@3.x&lt;/code&gt; you're fine until your Renovate bot opens a PR. If your pipeline installs &lt;code&gt;@shopify/cli&lt;/code&gt; latest on every run, it breaks the moment Shopify publishes the May 2026 release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;--force&lt;/code&gt; is going away
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--force&lt;/code&gt; today bypasses every confirmation prompt — including "you are about to permanently delete an extension from every shop that has this app installed." That's a footgun in an interactive terminal. In CI, where nobody reads the prompt anyway, it's a data-loss hazard on someone else's store.&lt;/p&gt;

&lt;p&gt;Shopify's fix is to split the single big-hammer flag into two narrower ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--allow-updates&lt;/code&gt;&lt;/strong&gt; — permits adding and modifying extensions, still blocks deletions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--allow-deletes&lt;/code&gt;&lt;/strong&gt; — permits deletions, intended only for manual runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;--force&lt;/code&gt; was equivalent to &lt;code&gt;--allow-updates --allow-deletes&lt;/code&gt;. The removal forces you to choose: does this pipeline actually need to delete extensions, or does it only update them?&lt;/p&gt;

&lt;p&gt;For the vast majority of deploy pipelines, the answer is "only update." Use &lt;code&gt;--allow-updates&lt;/code&gt;. Reserve &lt;code&gt;--allow-deletes&lt;/code&gt; for manual operator runs where the person running the command has confirmed what's about to disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where &lt;code&gt;--force&lt;/code&gt; is probably hiding
&lt;/h2&gt;

&lt;p&gt;Search the pipeline files, not the source code:&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;-n&lt;/span&gt; &lt;span class="s2"&gt;"force"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'.github/**'&lt;/span&gt; &lt;span class="s1"&gt;'.gitlab-ci*'&lt;/span&gt; &lt;span class="s1"&gt;'*.yml'&lt;/span&gt; &lt;span class="s1"&gt;'*.yaml'&lt;/span&gt; &lt;span class="s1"&gt;'package.json'&lt;/span&gt; &lt;span class="s1"&gt;'scripts/**'&lt;/span&gt;
git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"shopify app deploy&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;shopify app release"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common hiding spots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions workflow steps&lt;/strong&gt; — &lt;code&gt;run: shopify app deploy --force&lt;/code&gt; in a step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package.json scripts&lt;/strong&gt; — &lt;code&gt;"deploy": "shopify app deploy --force --reset"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Makefiles&lt;/strong&gt; — &lt;code&gt;deploy: shopify app deploy --force&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shell wrappers&lt;/strong&gt; — &lt;code&gt;scripts/deploy.sh&lt;/code&gt;, &lt;code&gt;bin/release&lt;/code&gt;, any custom &lt;code&gt;./deploy&lt;/code&gt; that shells out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs and runbooks&lt;/strong&gt; — onboarding READMEs that tell new engineers to "just run &lt;code&gt;shopify app deploy --force&lt;/code&gt;."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The migration that actually works
&lt;/h2&gt;

&lt;p&gt;For a standard CI/CD deploy pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt;&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx @shopify/cli@latest app deploy --force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt;&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx @shopify/cli@latest app deploy --allow-updates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the 90% case — merges to main ship extension updates, no deletions. If a PR deletes an extension and the pipeline needs to propagate the deletion, you have two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gate deletion behind a manual step.&lt;/strong&gt; A second workflow, triggered by a &lt;code&gt;workflow_dispatch&lt;/code&gt; or a specific tag, running &lt;code&gt;shopify app deploy --allow-updates --allow-deletes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect the deletion and promote the run.&lt;/strong&gt; Parse the &lt;code&gt;shopify app deploy --dry-run&lt;/code&gt; output and branch: if the diff includes deletions, require an approver before re-running with &lt;code&gt;--allow-deletes&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Avoid the temptation to just add &lt;code&gt;--allow-deletes&lt;/code&gt; to every pipeline. That's the footgun &lt;code&gt;--force&lt;/code&gt; was — the one Shopify is removing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the silent version of this failure looks like
&lt;/h2&gt;

&lt;p&gt;The loud failure — "unknown flag" — is the easy case. You see it, you fix it.&lt;/p&gt;

&lt;p&gt;The quiet failure is worse. Some pipelines add &lt;code&gt;--allow-updates --allow-deletes&lt;/code&gt; as a global replacement for &lt;code&gt;--force&lt;/code&gt;, thinking they've preserved the old behavior. They have — including the footgun. Six weeks later, a refactor PR removes an extension that was intentional. The deploy fires, the extension vanishes from every install, merchant support tickets spike. No alert triggered. The CLI did exactly what the pipeline asked.&lt;/p&gt;

&lt;p&gt;"Match the old behavior" is the wrong migration strategy here. "Match the old behavior &lt;strong&gt;that we actually needed&lt;/strong&gt;" is the right one, and for most teams that's just &lt;code&gt;--allow-updates&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;A CLI is an API. The flags, exit codes, and output format are the contract. Every pinned &lt;code&gt;@shopify/cli&lt;/code&gt; version in your pipeline is an implicit dependency on a specific version of that contract. Bump the dependency, contract shifts, code written against the old contract breaks.&lt;/p&gt;

&lt;p&gt;Shopify in the last six months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CLI &lt;code&gt;--force&lt;/code&gt; flag removal (May 2026)&lt;/li&gt;
&lt;li&gt;Checkout metafield deprecation (April 2026)&lt;/li&gt;
&lt;li&gt;Mandatory expiring tokens (April 2026)&lt;/li&gt;
&lt;li&gt;RBAC enforcement on admin APIs (April 2026)&lt;/li&gt;
&lt;li&gt;API version 2025-01 breaking changes (January 2026)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these breaks a different surface. Their common property is that none of them trigger anything on your side unless you're instrumenting the Shopify developer changelog or running integration tests against the live CLI and APIs on a schedule.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git grep&lt;/code&gt; every pipeline file for &lt;code&gt;shopify app deploy --force&lt;/code&gt; and &lt;code&gt;shopify app release --force&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;--force&lt;/code&gt; with &lt;code&gt;--allow-updates&lt;/code&gt; for automated pipelines.&lt;/li&gt;
&lt;li&gt;Create a separate, manually-triggered workflow for deletions that uses &lt;code&gt;--allow-updates --allow-deletes&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;--dry-run&lt;/code&gt; to a pre-deploy step and fail the pipeline if the diff includes extension deletions the PR didn't explicitly flag.&lt;/li&gt;
&lt;li&gt;Unpin &lt;code&gt;@shopify/cli&lt;/code&gt; in a canary job so the next breaking CLI change shows up in a non-production pipeline first.&lt;/li&gt;
&lt;li&gt;Update internal runbooks and onboarding docs — &lt;code&gt;--force&lt;/code&gt; is about to become a red herring.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bigger point: your CI/CD depends on third-party CLIs and APIs whose contracts change faster than your review cadence. Either you monitor those contracts continuously, or you find out when the pipeline turns red on a Tuesday morning.&lt;/p&gt;




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

</description>
      <category>shopify</category>
      <category>devops</category>
      <category>cicd</category>
      <category>api</category>
    </item>
    <item>
      <title>Supabase's May 30 Default Flip: Your Newly Created Tables Will Stop Reaching the Client With permission denied for table</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Mon, 04 May 2026 04:02:22 +0000</pubDate>
      <link>https://dev.to/flarecanary/supabases-may-30-default-flip-your-newly-created-tables-will-stop-reaching-the-client-with-1cbo</link>
      <guid>https://dev.to/flarecanary/supabases-may-30-default-flip-your-newly-created-tables-will-stop-reaching-the-client-with-1cbo</guid>
      <description>&lt;p&gt;Two Supabase changes in May change the rules for how new tables and the GraphQL endpoint reach your client. Both are documented in the &lt;a href="https://supabase.com/changelog" rel="noopener noreferrer"&gt;Supabase changelog&lt;/a&gt;, both have explicit cutoff dates, and both will silently land in production for teams that aren't watching.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;May 18, 2026&lt;/strong&gt; — &lt;code&gt;pg_graphql&lt;/code&gt; is no longer enabled by default. New projects ship without the extension; existing projects with zero GraphQL traffic over the prior 30 days get it disabled. (&lt;a href="https://supabase.com/changelog/42180-breaking-change-pg-graphql-no-longer-enabled-automatically-within-approx-3-weeks" rel="noopener noreferrer"&gt;Jan 26 changelog&lt;/a&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;May 30, 2026&lt;/strong&gt; — "Automatically expose new tables and functions" is OFF by default for all newly created projects. New tables in &lt;code&gt;public&lt;/code&gt; are no longer auto-granted to the API roles &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt;, &lt;code&gt;service_role&lt;/code&gt;. (&lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;Apr 28 changelog&lt;/a&gt;.) The same rule reaches existing projects on &lt;strong&gt;October 30, 2026&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting one is May 30. The pg_graphql change is loud — your &lt;code&gt;/graphql/v1&lt;/code&gt; endpoint returns extension-not-found and you fix it in five minutes. The exposure-default flip has a different shape, the one I keep writing this column about: &lt;strong&gt;the migration applies, the table exists, the dashboard shows it, and your client gets a 403 the first time it ships&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the thirteenth provider in the running silent-breakage tally, and the failure mode pairs with the &lt;a href="https://dev.to/flarecanary/kubernetes-136-removed-gitrepo-volumes-your-helm-charts-pass-validation-your-pods-dont-schedule-4g51"&gt;Kubernetes 1.36 &lt;code&gt;gitRepo&lt;/code&gt; article&lt;/a&gt; — every pre-deploy gate passes, the failure surface is &lt;em&gt;post-apply&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Flips on May 30
&lt;/h2&gt;

&lt;p&gt;The mechanism isn't a feature flag in your &lt;code&gt;supabase/config.toml&lt;/code&gt;. It's PostgREST default privileges. The "expose new tables" toggle, when on, runs an equivalent of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When off — the new default for any project created on or after May 30 — that grant doesn't fire. Your migration creates a table, the table is fully real in Postgres, but PostgREST's API roles can't read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- supabase/migrations/20260601_add_invoices.sql, ships fine, applies fine:&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&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;The first call from the SDK against the new project:&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// data: null&lt;/span&gt;
&lt;span class="c1"&gt;// error: {&lt;/span&gt;
&lt;span class="c1"&gt;//   code: '42501',&lt;/span&gt;
&lt;span class="c1"&gt;//   message: 'permission denied for table invoices',&lt;/span&gt;
&lt;span class="c1"&gt;//   hint: 'Grant the required privileges...',&lt;/span&gt;
&lt;span class="c1"&gt;//   details: null&lt;/span&gt;
&lt;span class="c1"&gt;// }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres error code &lt;code&gt;42501&lt;/code&gt; is &lt;code&gt;insufficient_privilege&lt;/code&gt;. That's the string developers Google. The error is loud — a 403 from &lt;code&gt;/rest/v1/invoices&lt;/code&gt; — but it's loud &lt;em&gt;in production after deploy&lt;/em&gt;, not in CI, not in &lt;code&gt;npx supabase start&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Local and Tests Don't Catch It
&lt;/h2&gt;

&lt;p&gt;The whole point of the local Supabase CLI stack is that it mirrors prod. It mostly does — but the new default flip is a &lt;em&gt;project-creation-time&lt;/em&gt; setting, and the local stack inherits the old behavior. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npx supabase start&lt;/code&gt; runs PostgREST against a local Postgres seeded with the legacy default privileges. Migrations from your &lt;code&gt;supabase/migrations/&lt;/code&gt; directory get the auto-grant. Local dev queries succeed.&lt;/li&gt;
&lt;li&gt;Your CI runs the same local stack. Tests pass.&lt;/li&gt;
&lt;li&gt;Production projects created before May 30 also keep the old behavior — until October 30, when existing projects get migrated. So the same migration that worked on a March-created project will silently 403 on a June-created sibling project, even with identical code.&lt;/li&gt;
&lt;li&gt;The dashboard shows the table in the Table Editor. Schema is correct. RLS policies you wrote apply correctly to the API roles — once the underlying grants exist. Without grants, the RLS check never happens; PostgREST short-circuits at the privilege layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rough mental model: &lt;strong&gt;your test environment is a March 2026 project. Your new prod environment, the staging clone you spun up after May 30, the per-customer DB-per-tenant project you provision in your onboarding flow — those are June 2026 projects, and they need explicit grants.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The supabase-js Failure Shape
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;supabase-js&lt;/code&gt; surfaces this as a structured error, not an exception. Code that reads &lt;code&gt;data&lt;/code&gt; without checking &lt;code&gt;error&lt;/code&gt; quietly receives &lt;code&gt;null&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="c1"&gt;// Looks reasonable, ships often:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;   &lt;span class="c1"&gt;// returns [] forever in the new default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern is everywhere. If your render layer treats empty &lt;code&gt;[]&lt;/code&gt; as "no data," the page loads, the empty-state UI shows, and nobody on the team realizes the API returned a 403 — they think the table is empty. The error is in the response, but unreached.&lt;/p&gt;

&lt;p&gt;Same shape in supabase-py:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&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="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# res.data is None
# res.error is {'code': '42501', 'message': 'permission denied for table invoices', ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same in supabase-flutter, supabase-swift, and any direct &lt;code&gt;fetch&lt;/code&gt; against &lt;code&gt;/rest/v1/*&lt;/code&gt;. There's no SDK version that escapes it — the change is server-side, in PostgREST role grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pg_graphql Disablement Looks Like (May 18)
&lt;/h2&gt;

&lt;p&gt;Different shape, easier to fix. Code that hits &lt;code&gt;/graphql/v1&lt;/code&gt; or uses &lt;code&gt;.schema('graphql_public').rpc('graphql', ...)&lt;/code&gt; gets either an extension-not-found error or a 404, depending on which endpoint your SDK chose. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;pg_graphql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in the dashboard: Database → Extensions → enable &lt;code&gt;pg_graphql&lt;/code&gt;. Once enabled, the extension behaves identically to before. The only nuance is that &lt;em&gt;existing&lt;/em&gt; projects with zero GraphQL traffic over the prior 30 days get it auto-disabled on May 18, so a project that was working could go dark if its GraphQL traffic was thin.&lt;/p&gt;

&lt;p&gt;Pair this with the May 30 grant change and a brand-new project gets to do both: enable the extension manually &lt;em&gt;and&lt;/em&gt; grant the API roles read access on every table you want exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Schema Drift Tools and Schema Validators Don't Help
&lt;/h2&gt;

&lt;p&gt;This is the awkward part for anyone who already has guardrails on their database changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migration linters&lt;/strong&gt; (sqlfluff, the Supabase advisor's lint pass) read the migration SQL and tell you the schema is sound. The schema &lt;em&gt;is&lt;/em&gt; sound. The grants aren't the schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type generators&lt;/strong&gt; (&lt;code&gt;supabase gen types typescript&lt;/code&gt;) introspect the schema, write &lt;code&gt;Database['public']['Tables']['invoices']&lt;/code&gt;, and your TypeScript compiles. The generator reads structure, not privileges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod / Pydantic / runtime validators&lt;/strong&gt; that wrap the SDK response don't fire — &lt;code&gt;data&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt; is populated, no schema gets validated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smoke tests against staging&lt;/strong&gt; catch it &lt;em&gt;only if staging is a post-May-30 project&lt;/em&gt;. If staging was created in March, it has the old default and won't reproduce the failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Fix You Want in Every New Migration
&lt;/h2&gt;

&lt;p&gt;The pragmatic shape for migrations going forward — write the grants explicitly, even on projects that still have the auto-grant default. Your migration becomes portable across project creation dates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;amount_cents&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Make this migration work on projects created after May 30, 2026:&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Plus your RLS policies as before:&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users see own invoices"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the auto-grant default back on a specific project — Database Settings → "Automatically expose new tables and functions" — toggle on. That just re-installs the &lt;code&gt;default privileges&lt;/code&gt; rule shown above; it does not retroactively grant existing tables.&lt;/p&gt;

&lt;p&gt;Your Security Advisor in the dashboard will flag missing grants on tables you have RLS policies for. Read that panel. It is the only place the system tells you "this table has policies but isn't reachable."&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Detect This Class of Change
&lt;/h2&gt;

&lt;p&gt;The general defense, restated for the database flavor of silent breakage:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Spin up a fresh Supabase project as part of your CI for migration tests.&lt;/strong&gt; Not a local stack — an actual Supabase-hosted project, created today. The local stack inherits the old defaults and won't reproduce. A new project will. (For most teams this is a per-PR ephemeral project against a free-tier org.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for &lt;code&gt;42501&lt;/code&gt; errors at the edge.&lt;/strong&gt; Add a request-level log on PostgREST 403s with body content. The shape is consistent enough to alert on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your tables list against your grants.&lt;/strong&gt; A nightly query that diffs &lt;code&gt;pg_class&lt;/code&gt; entries against &lt;code&gt;information_schema.role_table_grants&lt;/code&gt; for the API roles will surface tables that exist but aren't reachable. Run it on every project, not just dev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the project creation date in your runbook.&lt;/strong&gt; "All our prod projects were created in 2025" matters now in a way it didn't last quarter. October 30, 2026 is the date that flips this for all existing projects too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Pattern, Now Thirteen Months In
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;What Goes Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Basil&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Subscription.current_period_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moved to &lt;code&gt;items[]&lt;/code&gt;; old reads return &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pull_request.merge_commit_sha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;null&lt;/code&gt; on closed PRs in 2026-03-10 ver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Org security fields&lt;/td&gt;
&lt;td&gt;PATCH returns 200, applies nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Responses &lt;code&gt;input_text&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejected with &lt;code&gt;Invalid value&lt;/code&gt; error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot&lt;/td&gt;
&lt;td&gt;Contacts v1 endpoints&lt;/td&gt;
&lt;td&gt;Return 200 with &lt;code&gt;list-memberships&lt;/code&gt; silently dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth0&lt;/td&gt;
&lt;td&gt;TLS handshake&lt;/td&gt;
&lt;td&gt;Weak ciphers start returning &lt;code&gt;handshake_failure&lt;/code&gt; Jun 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.de1.twilio.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed; regional domains never actually routed regionally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;Checkout &lt;code&gt;metafields&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;undefined&lt;/code&gt; after 2026-04; orders ship without app data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes 1.36&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gitRepo&lt;/code&gt; volumes&lt;/td&gt;
&lt;td&gt;Pass validation, fail at deploy with FailedMount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-3-haiku-20240307&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns model-retired error after Apr 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;DALL·E 2/3&lt;/td&gt;
&lt;td&gt;Retired May 12; per-image billing flips to per-token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exa&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/research&lt;/code&gt; + crawl-date filters&lt;/td&gt;
&lt;td&gt;404, parameters silently ignored, fields &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Realtime&lt;/td&gt;
&lt;td&gt;Audio/text/transcript event names&lt;/td&gt;
&lt;td&gt;Renamed; old listeners silently never fire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supabase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PostgREST default privileges&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;New tables 403 with &lt;code&gt;42501&lt;/code&gt;; CI doesn't repro&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fourteen providers. Same shared shape: the system answers, the SDK is fine, the &lt;em&gt;thing&lt;/em&gt; your code expects to reach is gated by something your local environment doesn't model.&lt;/p&gt;

&lt;p&gt;If you operate Supabase projects, the action items are short. New migrations: ship the grants explicitly. New projects after May 30: enable pg_graphql if you use it, and turn on auto-exposure if you'd rather not write grants per table. Every project: read the Security Advisor panel. October 30 is the date this hits projects you've already deployed against.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Building
&lt;/h2&gt;

&lt;p&gt;I'm working on &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for exactly this class of post-deploy surprise. Point it at the API surfaces your application depends on — REST, GraphQL, and the response &lt;em&gt;shape&lt;/em&gt; — and it polls them on a schedule, learns the response vocabulary, and alerts when a field disappears, a status code flips, or the privilege layer starts answering 403 where it used to answer rows. Free tier covers up to five endpoints, useful for keeping a watch on a Supabase project alongside the upstream APIs you call.&lt;/p&gt;

&lt;p&gt;You don't need a tool for this. You do need a habit. The Supabase changelog covers both May cutoffs in plain language. Anyone reading it will catch them. The half-broken state still ships somewhere — to a team that provisions a per-tenant project from a script, to a team whose staging is a different vintage from prod, to a team that trusted local dev to model production privileges.&lt;/p&gt;

&lt;p&gt;That's the gap. The schema being correct isn't enough. The grants matter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your Supabase project trips on the May 30 default flip — or if a &lt;code&gt;42501 permission denied for table&lt;/code&gt; error catches you off guard — I'd like to hear about it. The "code is right, schema is right, privileges aren't" failures are exactly the ones I'm tracking. Drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>api</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>GitHub Just Removed merge_commit_sha From Pull Request Responses — Your Release Bot Is Probably Tagging null</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 03 May 2026 13:01:05 +0000</pubDate>
      <link>https://dev.to/flarecanary/github-just-removed-mergecommitsha-from-pull-request-responses-your-release-bot-is-probably-1le</link>
      <guid>https://dev.to/flarecanary/github-just-removed-mergecommitsha-from-pull-request-responses-your-release-bot-is-probably-1le</guid>
      <description>&lt;p&gt;GitHub's &lt;a href="https://docs.github.com/en/rest/about-the-rest-api/breaking-changes?apiVersion=2026-03-10" rel="noopener noreferrer"&gt;2026-03-10 REST API version&lt;/a&gt; ships a quiet but consequential breaking change: the &lt;code&gt;merge_commit_sha&lt;/code&gt; property is gone from pull request responses. 21 endpoints affected. There is no error, no 410, no migration warning header — the field just stops appearing in the JSON.&lt;/p&gt;

&lt;p&gt;For CI/CD code that does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pr&lt;/span&gt; &lt;span class="p"&gt;}&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;octokit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pulls&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="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pull_number&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;tagDeploymentArtifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;merge_commit_sha&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You now tag with &lt;code&gt;undefined&lt;/code&gt;. The deployment still ships. The artifact registry still accepts the upload. Six weeks later, somebody asks "which commit produced this build?" and the answer is &lt;code&gt;undefined-1747291204&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is incident #7 in our silent-breakage series (&lt;a href="https://dev.to/flarecanary"&gt;GitHub PushEvent&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary"&gt;Stripe Basil&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary"&gt;Shopify 2025-01&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary"&gt;OpenAI Responses&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary"&gt;Twilio regional&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary"&gt;HubSpot Contacts v1&lt;/a&gt;). The pattern keeps repeating because removing a field from a JSON response is the cheapest possible breaking change for the API provider and the most invisible possible breaking change for the consumer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's removed and where
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;merge_commit_sha&lt;/code&gt; disappears from 21 PR-bearing endpoints, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /repos/{owner}/{repo}/pulls&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /repos/{owner}/{repo}/pulls/{pull_number}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /repos/{owner}/{repo}/pulls&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PATCH /repos/{owner}/{repo}/pulls/{pull_number}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /repos/{owner}/{repo}/issues/events&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /repos/{owner}/{repo}/issues/{issue_number}/events&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Search results that embed PR objects&lt;/li&gt;
&lt;li&gt;Project card payloads that link to PRs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same release also removes the singular &lt;code&gt;assignee&lt;/code&gt; field from 31 Issue and PR endpoints. From the changelog:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The singular &lt;code&gt;assignee&lt;/code&gt; field has been marked as 'closing down' for years and duplicates information available in the &lt;code&gt;assignees&lt;/code&gt; array.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;True — and the migration is mechanical. Read from &lt;code&gt;assignees[0]&lt;/code&gt; instead of &lt;code&gt;assignee&lt;/code&gt;. Write &lt;code&gt;assignees: [login]&lt;/code&gt; instead of &lt;code&gt;assignee: login&lt;/code&gt;. The footgun is that &lt;code&gt;assignee&lt;/code&gt; returning &lt;code&gt;undefined&lt;/code&gt; looks identical to "this PR has no assignee," and a lot of routing logic gates on exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you don't get an error
&lt;/h2&gt;

&lt;p&gt;The REST API breaking-change rollout works by version pinning. If you send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-GitHub-Api-Version: 2022-11-28
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…you keep the old field. If you send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-GitHub-Api-Version: 2026-03-10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or no version header at all and your client SDK has updated its default — the field is gone.&lt;/p&gt;

&lt;p&gt;Octokit, PyGithub, go-github, and the various community SDKs upgrade their defaults on their own schedules. Your &lt;code&gt;package.json&lt;/code&gt; says &lt;code&gt;^21.0.0&lt;/code&gt;, the maintainer ships &lt;code&gt;21.4.7&lt;/code&gt; next month with the new default version header, your &lt;code&gt;npm install&lt;/code&gt; picks it up on the next CI run, and now your release bot is tagging &lt;code&gt;undefined&lt;/code&gt;. Nothing in your repo changed. Nothing in the API changed for clients still pinning the old version. The change rides in on a transitive bump.&lt;/p&gt;

&lt;h2&gt;
  
  
  The non-obvious replacement
&lt;/h2&gt;

&lt;p&gt;The official guidance points to the &lt;code&gt;pull_request.merge_commit_sha&lt;/code&gt; field on the &lt;strong&gt;webhook payload&lt;/strong&gt; (not the API response) and to the merge commit's SHA reachable via:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;GET /repos/&lt;span class="o"&gt;{&lt;/span&gt;owner&lt;span class="o"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;repo&lt;span class="o"&gt;}&lt;/span&gt;/commits/&lt;span class="o"&gt;{&lt;/span&gt;ref&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# where ref is the head of the default branch immediately after merge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is a one-line swap.&lt;/p&gt;

&lt;p&gt;Webhook payloads still carry &lt;code&gt;merge_commit_sha&lt;/code&gt; because GitHub versions webhooks separately from the REST API. If your release bot is webhook-driven, you're fine — the field is in the &lt;code&gt;pull_request.closed&lt;/code&gt; event payload. If your release bot is poll-driven (CI runs nightly, asks "what merged today, tag those artifacts"), you have to reconstruct the merge commit by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reading the PR's &lt;code&gt;base.ref&lt;/code&gt; (the target branch)&lt;/li&gt;
&lt;li&gt;Pulling commit history on that branch&lt;/li&gt;
&lt;li&gt;Finding the merge commit by the PR number in the commit message (&lt;code&gt;Merge pull request #N&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Or — for squash/rebase merges — there is no single merge SHA, and you have to use &lt;code&gt;head.sha&lt;/code&gt; of the PR plus tracking metadata your bot wrote earlier&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The squash-merge case is the one most release tooling gets wrong on the rewrite. There never was a &lt;code&gt;merge_commit_sha&lt;/code&gt; for squash merges in the strict sense — GitHub returned the SHA of the squashed-in commit on the base branch — but consumers treated it as canonical. Without that field, the PR head SHA and the resulting commit on &lt;code&gt;main&lt;/code&gt; are different SHAs with no GitHub-provided link between them.&lt;/p&gt;

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

&lt;p&gt;Your CI pipeline tests hit a fixture PR JSON. The fixture has &lt;code&gt;merge_commit_sha&lt;/code&gt;. The test asserts the tag string is well-formed.&lt;/p&gt;

&lt;p&gt;Three things have to be true for that test to fail before the rollout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your fixtures were regenerated against the new API version (they weren't)&lt;/li&gt;
&lt;li&gt;Your test runner sends the new &lt;code&gt;X-GitHub-Api-Version&lt;/code&gt; header (it doesn't, unless you wrote it)&lt;/li&gt;
&lt;li&gt;Your assertion checks &lt;code&gt;typeof sha === 'string' &amp;amp;&amp;amp; sha.length === 40&lt;/code&gt; and not just &lt;code&gt;sha != null&lt;/code&gt; (most don't)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the test stays green. The same pattern as every other silent drift incident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Where tests missed it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;GitHub PushEvent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;commits&lt;/code&gt; field silently dropped&lt;/td&gt;
&lt;td&gt;Tests didn't assert field presence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Stripe Basil&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;current_period_end&lt;/code&gt; moved to &lt;code&gt;items&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tests used Checkout fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Shopify 2025-01&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fulfillmentHold&lt;/code&gt; type change&lt;/td&gt;
&lt;td&gt;Tests mocked the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;OpenAI Responses&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;input_text&lt;/code&gt; removed for assistants&lt;/td&gt;
&lt;td&gt;Tests covered request role=user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Twilio regional&lt;/td&gt;
&lt;td&gt;Regional domains stop resolving&lt;/td&gt;
&lt;td&gt;Tests don't hit prod DNS paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;HubSpot Contacts v1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list-memberships&lt;/code&gt; returns empty&lt;/td&gt;
&lt;td&gt;Tests asserted against sandbox fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;GitHub merge_commit_sha&lt;/td&gt;
&lt;td&gt;Field removed from PR responses&lt;/td&gt;
&lt;td&gt;Tests used pre-rollout fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pattern holds. The breaking change is always in a field a test isn't asserting against — or in a layer (transitive SDK upgrade, version-pinned header) the test isn't exercising.&lt;/p&gt;

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

&lt;p&gt;Three actions, in priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grep your codebase for &lt;code&gt;merge_commit_sha&lt;/code&gt;.&lt;/strong&gt; Every read site is a candidate failure. Tag every assignment to a deployment artifact, release name, or git tag as a critical path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grep for &lt;code&gt;pr.assignee&lt;/code&gt; or &lt;code&gt;issue.assignee&lt;/code&gt; (singular).&lt;/strong&gt; Routing/notification code keyed on this field is the next-largest blast radius after release tagging.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pin &lt;code&gt;X-GitHub-Api-Version: 2022-11-28&lt;/code&gt; as a stopgap if you can't fix all the read sites this week.&lt;/strong&gt; GitHub supports old versions for ~24 months. This buys time, not a fix — schedule the migration before the version sunsets.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your release bot is silently tagging &lt;code&gt;undefined&lt;/code&gt;, you usually find out 90 days later when somebody is paged at 3 AM and can't trace the deploy. Worth fixing on a Tuesday afternoon.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for this layer: poll third-party APIs on a schedule, watch the response shape, page when a field stops appearing. The GitHub merge SHA removal is the textbook incident — no error, no warning, just a field that used to be there and isn't. APIs break this way constantly. We catch it before the 3 AM page.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We track API drift incidents in real time. If your release tooling reads &lt;code&gt;merge_commit_sha&lt;/code&gt; and you haven't audited it for the 2026-03-10 API version, that null is already in your build pipeline somewhere.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>api</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>OpenAI Realtime Beta Disappears May 7 — Your Voice Agent's Audio Handlers Will Stop Firing With No Error</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sun, 03 May 2026 04:02:21 +0000</pubDate>
      <link>https://dev.to/flarecanary/openai-realtime-beta-disappears-may-7-your-voice-agents-audio-handlers-will-stop-firing-with-no-1fn</link>
      <guid>https://dev.to/flarecanary/openai-realtime-beta-disappears-may-7-your-voice-agents-audio-handlers-will-stop-firing-with-no-1fn</guid>
      <description>&lt;p&gt;On May 7, 2026 — five days from now — OpenAI &lt;a href="https://platform.openai.com/docs/deprecations" rel="noopener noreferrer"&gt;removes the Realtime API beta&lt;/a&gt;. If you have a voice agent, transcription pipeline, or any WebSocket/WebRTC integration with &lt;code&gt;gpt-4o-realtime-preview&lt;/code&gt;, you have a long weekend's worth of work to do, and most of it isn't the part the migration guide warns about.&lt;/p&gt;

&lt;p&gt;The loud failures are easy. The WebSocket returns 401, the WebRTC connection won't establish, your &lt;code&gt;session.update&lt;/code&gt; gets rejected. Those break in dev, you fix them, you ship.&lt;/p&gt;

&lt;p&gt;The interesting failures — and the ones I keep writing this column about — are the silent ones. The connection works, the model responds, your tests pass, and audio just stops coming out of the speaker. Or text stops streaming. Or the voice changes. Or a function call output silently flips from text to audio. Code that was correct against the beta interface is now correct-shaped against an interface that's renamed half the events it emits.&lt;/p&gt;

&lt;p&gt;This is the twelfth provider in the running tally and it's the same shape every time: the SDK still validates, the response still parses, the field your code depends on just isn't being sent under that name anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Ones: Renamed Events
&lt;/h2&gt;

&lt;p&gt;This is where most of the silent breakage lives. The Realtime API streams a lot of event types over the WebSocket — partial text deltas, audio chunks, transcript chunks. In the GA protocol, three of the most-listened-for events were &lt;a href="https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/realtime-audio-preview-api-migration-guide" rel="noopener noreferrer"&gt;renamed&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Beta name&lt;/th&gt;
&lt;th&gt;GA name&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;response.text.delta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;response.output_text.delta&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;response.audio.delta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;response.output_audio.delta&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;response.audio_transcript.delta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;response.output_audio_transcript.delta&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your client uses a typed event dispatcher — &lt;code&gt;switch (msg.type)&lt;/code&gt;, or a &lt;code&gt;client.on("response.audio.delta", ...)&lt;/code&gt; style listener — the new event names just don't match. The handler isn't called. There's no error. The connection is healthy, the server is sending bytes, and your audio buffer never gets fed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Beta-era code, still compiles, still connects, plays no audio:&lt;/span&gt;
&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;response.audio.delta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;audioBuffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// In GA, this event is named response.output_audio.delta.&lt;/span&gt;
&lt;span class="c1"&gt;// The above handler will never fire. No exception, no warning,&lt;/span&gt;
&lt;span class="c1"&gt;// just silence on the speaker.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The class of code that breaks here is "dispatch on event name." Every voice agent I've seen written against the beta has a &lt;code&gt;switch&lt;/code&gt; or a listener registry keyed on these strings. They don't throw on unknown event names — that would be the right design — they silently no-op.&lt;/p&gt;

&lt;p&gt;The tells are subtle. Tests that mock the WebSocket transport keep passing because the test fixtures still use the old names. Recorded interactions replay correctly. The first sign in production is a user saying "the bot just stopped talking to me," and your logs show a successful session with content streaming and no errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Almost-Silent One: Content Type Renames
&lt;/h2&gt;

&lt;p&gt;Inside the conversation item shape, two type tags were renamed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type: "text"&lt;/code&gt; → &lt;code&gt;type: "output_text"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: "audio"&lt;/code&gt; → &lt;code&gt;type: "output_audio"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same failure mode. If you have rendering code that switches on &lt;code&gt;content[i].type&lt;/code&gt;, it now hits the default branch — usually "render nothing" or "log unknown type and continue."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Beta:
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&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;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;render_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;play_audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="c1"&gt;# default: skip silently
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In GA, every assistant response part takes the default branch. The conversation appears to send empty turns. No exception. The model is responding correctly; your render layer just doesn't recognize the shape anymore.&lt;/p&gt;

&lt;p&gt;This is identical in spirit to &lt;a href="https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-kc5"&gt;Stripe's Basil migration&lt;/a&gt; — the field you read still exists in the response, it's just spelled differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Other Silent One: Restructured Session Config
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session.update&lt;/code&gt; is the event you send to configure voice, transcription model, modalities, and so on. In the beta, much of this was flat. In GA, it nested into &lt;code&gt;session.audio.input&lt;/code&gt; and &lt;code&gt;session.audio.output&lt;/code&gt;. Voice selection, for example, moved from a top-level field to &lt;code&gt;session.audio.output.voice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your client sends old-shape config to a GA endpoint, two things can happen depending on which fields you set:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server rejects the &lt;code&gt;session.update&lt;/code&gt; (loud — easy to fix).&lt;/li&gt;
&lt;li&gt;The server accepts what it understands, ignores what it doesn't, and keeps defaults for the rest (silent — voice silently flips to the default &lt;code&gt;alloy&lt;/code&gt;, transcription model silently falls back, etc.).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mixed shape — some fields recognized, others ignored — is the worst case. The session is configured, just not the way you wrote it. Your "always use the &lt;code&gt;verse&lt;/code&gt; voice for our brand" code now uses the default voice and nobody notices until QA listens to a recording.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Loud Ones (For Completeness)
&lt;/h2&gt;

&lt;p&gt;These break visibly. List them so you know what to expect during migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OpenAI-Beta: realtime=v1&lt;/code&gt; header.&lt;/strong&gt; Remove it. Sending it against the GA endpoint causes auth/route confusion — you'll see 401 or 404 depending on path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;api-version&lt;/code&gt; query parameter&lt;/strong&gt; (Azure path). Strip it from the URL. The GA endpoint format is &lt;code&gt;/openai/v1/realtime&lt;/code&gt;, no version suffix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;session.update&lt;/code&gt; missing the new &lt;code&gt;type&lt;/code&gt; field.&lt;/strong&gt; Required in GA. Must be &lt;code&gt;"realtime"&lt;/code&gt; for speech-to-speech or &lt;code&gt;"transcription"&lt;/code&gt; for audio-only. Server returns an explicit error if absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC ephemeral key endpoint.&lt;/strong&gt; Was a session creation flow; is now &lt;code&gt;POST /v1/realtime/client_secrets&lt;/code&gt;. Different request shape, different response shape. Old endpoint 404s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC connection URL.&lt;/strong&gt; Now &lt;code&gt;/v1/realtime/calls&lt;/code&gt;. Old browser SDP exchange path is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK version pins.&lt;/strong&gt; OpenAI Python ≥ 1.54.0, JavaScript ≥ 4.77.0, .NET ≥ 2.9.0. Older SDKs hard-fail against GA.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The migration guide covers these well. The ones above this list are where it under-warns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Tests Won't Catch It
&lt;/h2&gt;

&lt;p&gt;Same pattern as the previous eleven providers in this series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mocked Realtime in tests.&lt;/strong&gt; Your fixtures are recorded WebSocket transcripts from the beta. They still emit &lt;code&gt;response.audio.delta&lt;/code&gt;. Tests pass; production silently breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema validators don't help.&lt;/strong&gt; &lt;code&gt;zod&lt;/code&gt; / &lt;code&gt;pydantic&lt;/code&gt; schemas that accept &lt;code&gt;{ type: string, ... }&lt;/code&gt; with a string-enum will pass &lt;code&gt;output_audio&lt;/code&gt; as readily as &lt;code&gt;audio&lt;/code&gt;. The typo isn't a schema violation, it's a &lt;em&gt;meaning&lt;/em&gt; violation — the new value goes through a code path that expects different content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice agents don't have unit tests.&lt;/strong&gt; Most are tested by running them and listening. Silent audio is a noticeable bug, but only after a user reports it. Empty-text-stream renders look like the model "didn't say anything this turn" — easy to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs look healthy.&lt;/strong&gt; No exceptions, no 4xx, no warnings. The Realtime session metadata in the OpenAI dashboard shows successful turns. Every observability surface is green.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Detect This Class of Change
&lt;/h2&gt;

&lt;p&gt;The general defense, which I keep restating because it keeps working, is to watch the &lt;em&gt;shape&lt;/em&gt; of responses, not just the status code. For a streaming API like Realtime, that means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log the set of event types your client receives in a session.&lt;/strong&gt; A short script that subscribes to all events, hashes the names, and alerts when the hash changes. Catches every future event rename.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscribe to a wildcard / unknown-event handler that logs (don't silently drop).&lt;/strong&gt; If your event dispatcher has a &lt;code&gt;default:&lt;/code&gt; branch, make it loud — &lt;code&gt;console.warn("unhandled realtime event: " + msg.type)&lt;/code&gt;. You'd have caught this rename on day one of the GA rollout in dev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay a representative session in CI against a non-production realtime endpoint.&lt;/strong&gt; Capture the event-name set. Diff against last known good. This is what schema-drift monitoring does for REST APIs; the same idea applies to streaming protocols.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit &lt;code&gt;switch (msg.type)&lt;/code&gt; and event-listener registrations&lt;/strong&gt; in your codebase. &lt;code&gt;grep -r "response.text.delta\|response.audio.delta\|response.audio_transcript.delta"&lt;/code&gt; and update each call site. While you're there, add the wildcard branch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For ongoing monitoring: do this for every streaming provider you depend on, not just OpenAI. The streaming-event-name failure mode is generalizable — Anthropic streams events, Google's voice APIs do, every voice/agent provider does, and they all rename events between major versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern, Now Twelve Months In
&lt;/h2&gt;

&lt;p&gt;Twelfth provider in the running silent-breakage tally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;What Goes Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Basil&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Subscription.current_period_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moved to &lt;code&gt;items[]&lt;/code&gt;; old reads return &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pull_request.merge_commit_sha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;null&lt;/code&gt; on closed PRs in 2026-03-10 ver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Org security fields&lt;/td&gt;
&lt;td&gt;PATCH returns 200, applies nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Responses &lt;code&gt;input_text&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejected with &lt;code&gt;Invalid value&lt;/code&gt; error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot&lt;/td&gt;
&lt;td&gt;Contacts v1 endpoints&lt;/td&gt;
&lt;td&gt;Return 200 with &lt;code&gt;list-memberships&lt;/code&gt; silently dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth0&lt;/td&gt;
&lt;td&gt;TLS handshake&lt;/td&gt;
&lt;td&gt;Weak ciphers start returning &lt;code&gt;handshake_failure&lt;/code&gt; Jun 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.de1.twilio.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed; regional domains never actually routed regionally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;Checkout &lt;code&gt;metafields&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;undefined&lt;/code&gt; after 2026-04; orders ship without app data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes 1.36&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gitRepo&lt;/code&gt; volumes&lt;/td&gt;
&lt;td&gt;Pass validation, fail at deploy with FailedMount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-3-haiku-20240307&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns model-retired error after Apr 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;DALL·E 2/3&lt;/td&gt;
&lt;td&gt;Retired May 12; per-image billing flips to per-token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exa&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/research&lt;/code&gt; + crawl-date filters + &lt;code&gt;highlightScores&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;404, parameters silently ignored, fields &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI Realtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Audio/text/transcript event names&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Renamed; old listeners silently never fire&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Thirteen providers, thirteen different shapes, one shared failure mode: the surface still answers, the SDK still validates, the &lt;em&gt;thing&lt;/em&gt; your code reads or listens for is just not there under that name.&lt;/p&gt;

&lt;p&gt;If you ship a voice agent that depends on the Realtime API, today is the day. May 7 is on a Thursday. The week after that, "the bot stopped talking" tickets are a much harder bug than "I renamed my event handlers on May 2 because the migration guide said to."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Building
&lt;/h2&gt;

&lt;p&gt;I'm working on &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for exactly this class of bug. Point it at the API endpoints you depend on — REST, GraphQL, and now streaming — and it polls them on a schedule, learns the response shape and event vocabulary, and alerts when a field drops, a type flips, or an event renames. Free tier covers up to five endpoints, useful for keeping watch on your top external dependencies without building the monitor yourself.&lt;/p&gt;

&lt;p&gt;You don't need a tool for this. You do need a habit. The Realtime GA rollout has been documented well enough that any team paying attention will catch it. The silent half-broken state still ships somewhere — to a team that pinned an old SDK, or to one that wrote a strict event listener six months ago and forgot.&lt;/p&gt;

&lt;p&gt;That's the gap. HTTP 200 isn't enough. The connection succeeding isn't enough. The event names matter.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your voice agent or transcription pipeline trips on the Realtime migration this week — or any other silent schema change — I'd like to hear about it. The "no error, just silence on the speaker" failures are exactly the ones I'm tracking. Drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openai</category>
      <category>api</category>
      <category>monitoring</category>
      <category>voice</category>
    </item>
    <item>
      <title>Exa Just Removed /research and Started Silently Ignoring Two Date Filters — Your Agent Is Probably Pulling Stale Pages Right Now</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Sat, 02 May 2026 04:01:43 +0000</pubDate>
      <link>https://dev.to/flarecanary/exa-just-removed-research-and-started-silently-ignoring-two-date-filters-your-agent-is-probably-1p51</link>
      <guid>https://dev.to/flarecanary/exa-just-removed-research-and-started-silently-ignoring-two-date-filters-your-agent-is-probably-1p51</guid>
      <description>&lt;p&gt;On May 1, 2026 — today — Exa finished a three-step API deprecation that's been quietly half-breaking AI agents for the past two weeks. The hard cutoff today is the visible piece. The interesting part is what's been broken since April 15 with no error.&lt;/p&gt;

&lt;p&gt;Three things changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/research/v1&lt;/code&gt;&lt;/strong&gt; — sunset today. Calls now fail. Migration: &lt;code&gt;/search&lt;/code&gt; with &lt;code&gt;type: "deep-reasoning"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;startCrawlDate&lt;/code&gt; / &lt;code&gt;endCrawlDate&lt;/code&gt;&lt;/strong&gt; — silently ignored since April 15. Requests still return &lt;code&gt;200&lt;/code&gt;. The filters just don't apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resolvedSearchType&lt;/code&gt; and &lt;code&gt;highlightScores&lt;/code&gt;&lt;/strong&gt; — started returning &lt;code&gt;null&lt;/code&gt; on April 15. Hard-removed today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're calling Exa from an agent, RAG pipeline, or research tool, at least one of these is probably touching your code path. The endpoint cutoff is the loud one — your app errors and you find out. The other two are the silent-breakage class. Your code keeps running, your tests stay green, your output gets quietly worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent One: Crawl-Date Filters
&lt;/h2&gt;

&lt;p&gt;This is the part most teams will miss.&lt;/p&gt;

&lt;p&gt;Exa's &lt;code&gt;/search&lt;/code&gt; endpoint takes &lt;code&gt;startCrawlDate&lt;/code&gt; and &lt;code&gt;endCrawlDate&lt;/code&gt; parameters. They constrain results to pages crawled within a date window — useful when you want recent web content and don't want a 2019 blog post showing up because Google still ranks it.&lt;/p&gt;

&lt;p&gt;A typical usage pattern, paraphrased:&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latest LLM benchmarks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;startCrawlDate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-04-01T00:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;endCrawlDate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-05-01T00:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;numResults&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&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;Before April 15, you got pages crawled in April 2026.&lt;/p&gt;

&lt;p&gt;After April 15, Exa accepts the request, returns &lt;code&gt;200&lt;/code&gt;, and gives you whatever the search would return &lt;em&gt;without&lt;/em&gt; the date filter. The parameters are accepted, validated for type (still ISO 8601), and discarded.&lt;/p&gt;

&lt;p&gt;There's no error. There's no warning header. The &lt;a href="https://exa.ai/docs/changelog/may-2026-api-deprecations" rel="noopener noreferrer"&gt;Exa changelog&lt;/a&gt; is the only place this is documented:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"requests will succeed, but the filter will have no effect"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Exa reference docs &lt;em&gt;still list both parameters&lt;/em&gt; in the current request schema, with no deprecation marker. So the integration looks correct in every place a developer would look — except for the runtime behavior.&lt;/p&gt;

&lt;p&gt;The class of code that breaks here is "pull recent content for an LLM context window." If you're doing date-bounded research for an agent — "summarize what's been written about X this month" — the agent is now happily summarizing pages from any year. The output reads fine. Spot-checking ten queries you might not notice. The tells are subtle: results gradually skewing older, "this month's news" articles citing 2024 sources, RAG answers that are confidently wrong about &lt;em&gt;when&lt;/em&gt; something happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Almost-Silent One: Null Response Fields
&lt;/h2&gt;

&lt;p&gt;Two fields started returning &lt;code&gt;null&lt;/code&gt; on April 15:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resolvedSearchType&lt;/code&gt;&lt;/strong&gt; — when you call &lt;code&gt;/search&lt;/code&gt; with &lt;code&gt;type: "auto"&lt;/code&gt;, this told you which algorithm Exa actually used (neural, keyword, etc.). Useful for logging, for A/B comparisons, for tuning prompts based on which retrieval path won.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;highlightScores&lt;/code&gt;&lt;/strong&gt; — relevance scores for the highlights extracted from each result. Useful for filtering ("only show highlights above 0.7"), for re-ranking, for explaining results to users.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code that reads these fields now gets &lt;code&gt;null&lt;/code&gt; instead of a number or a string. Whether that breaks loudly depends entirely on how the consumer is written.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This silently degrades:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlightScores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// .filter on undefined &amp;gt; 0.7 → false for every item → empty array&lt;/span&gt;
&lt;span class="c1"&gt;// No error, just no results.&lt;/span&gt;

&lt;span class="c1"&gt;// This crashes loudly:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;highlightScores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&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="c1"&gt;// Cannot read property 'reduce' of null → 500 in your handler.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crash version gets caught in CI on the next run. The silent-empty version ships and your "top relevant snippets" feature returns nothing forever. Then on May 1, the field is removed entirely, the &lt;code&gt;null&lt;/code&gt; becomes &lt;code&gt;undefined&lt;/code&gt;, and downstream code that was previously handling &lt;code&gt;null&lt;/code&gt; may behave differently again.&lt;/p&gt;

&lt;p&gt;This is the same pattern as Stripe's &lt;code&gt;current_period_end&lt;/code&gt; move and Auth0's TLS cipher removal: the SDK still validates, the response still parses, the field your code depends on just isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Loud One: /research Endpoint
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/research/v1&lt;/code&gt; — the agentic research endpoint — is gone today. Calls return an error.&lt;/p&gt;

&lt;p&gt;Migration is to &lt;code&gt;/search&lt;/code&gt; with &lt;code&gt;type: "deep-reasoning"&lt;/code&gt; and an &lt;code&gt;outputSchema&lt;/code&gt; for structured output. From the &lt;a href="https://exa.ai/docs/reference/search" rel="noopener noreferrer"&gt;Exa docs&lt;/a&gt;, the new shape is:&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;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your research question"&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;"deep-reasoning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="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="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;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;Three things to watch in the migration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cost shape changes.&lt;/strong&gt; The new endpoint exposes &lt;code&gt;costDollars&lt;/code&gt; per request; budget code that hard-coded research-endpoint pricing will be wrong. Pull cost from the response, not from a constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming differs.&lt;/strong&gt; &lt;code&gt;/search&lt;/code&gt; supports &lt;code&gt;stream: true&lt;/code&gt;; if your &lt;code&gt;/research&lt;/code&gt; integration was synchronous, you can leave streaming off, but it's worth a look — agents tend to feel slow on &lt;code&gt;deep-reasoning&lt;/code&gt; queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output schema enforcement is opt-in.&lt;/strong&gt; With &lt;code&gt;/research&lt;/code&gt;, you wrote schemas and got typed output. With &lt;code&gt;/search&lt;/code&gt; deep-reasoning, you only get typed output if you pass &lt;code&gt;outputSchema&lt;/code&gt;. Forget it and you get free-form text. Consumers that did &lt;code&gt;JSON.parse(result.output)&lt;/code&gt; will throw.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the visible failure. Loud is preferable to silent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most Tests Won't Catch the Silent Pieces
&lt;/h2&gt;

&lt;p&gt;Standard ways teams miss this class of change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mocked Exa in tests.&lt;/strong&gt; If your test fixture is a recorded &lt;code&gt;/search&lt;/code&gt; response from March, it has &lt;code&gt;highlightScores&lt;/code&gt; and &lt;code&gt;resolvedSearchType&lt;/code&gt; populated. Your code reads them, your assertions pass. The live API now returns &lt;code&gt;null&lt;/code&gt;. Tests are green; production is degraded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;startCrawlDate&lt;/code&gt; parameter still in docs.&lt;/strong&gt; A developer auditing the integration today, looking at Exa's reference page, sees both parameters listed normally. The deprecation notice is on the changelog page only. So a code review of the integration doesn't catch it — the code matches the docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents don't have unit tests.&lt;/strong&gt; Most agent loops are tested by running them and eyeballing the output. The output of a date-unfiltered query looks similar enough to a date-filtered one that the difference doesn't jump out, especially if the underlying corpus is dominated by recent pages anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema validators pass.&lt;/strong&gt; If you're using &lt;code&gt;zod&lt;/code&gt; or &lt;code&gt;pydantic&lt;/code&gt; to validate the response, &lt;code&gt;null&lt;/code&gt; likely passes a nullable field schema, and a missing field passes an optional field schema. Validation errors are not the right signal here.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Detect This Class of Change
&lt;/h2&gt;

&lt;p&gt;The general defense is the same as for &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end" rel="noopener noreferrer"&gt;Stripe's Basil migration&lt;/a&gt;, &lt;a href="https://dev.to/flarecanary/github-just-removed-mergecommitsha-from-pull-request-responses-your-release-bot-is-probably-156d"&gt;GitHub's &lt;code&gt;merge_commit_sha&lt;/code&gt; removal&lt;/a&gt;, and &lt;a href="https://dev.to/flarecanary/openai-responses-api-started-rejecting-inputtext-with-no-warning-heres-the-fix-and-why-it-keeps-2l49"&gt;OpenAI's &lt;code&gt;input_text&lt;/code&gt; rejection&lt;/a&gt;: you have to watch the &lt;em&gt;shape&lt;/em&gt; of the response, not just the status code.&lt;/p&gt;

&lt;p&gt;Specific things you can do today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit &lt;code&gt;startCrawlDate&lt;/code&gt; / &lt;code&gt;endCrawlDate&lt;/code&gt; callers.&lt;/strong&gt; &lt;code&gt;grep -r "startCrawlDate\|endCrawlDate"&lt;/code&gt; across your codebase. Anywhere you pass these, the filter is now no-op. Decide if you can remove them, replace with &lt;code&gt;startPublishedDate&lt;/code&gt; / &lt;code&gt;endPublishedDate&lt;/code&gt; (which still work but mean something different — &lt;em&gt;published&lt;/em&gt; date as Exa understands it, not crawl date), or whether your use case requires a different data source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for &lt;code&gt;highlightScores&lt;/code&gt; reads.&lt;/strong&gt; Anywhere your code accesses this, it's now &lt;code&gt;null&lt;/code&gt; (and gone after today). Most consumers want to either drop the score-based logic or replace it with a re-ranker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for &lt;code&gt;resolvedSearchType&lt;/code&gt; reads.&lt;/strong&gt; Mostly used for logging or analytics — usually fine to drop, occasionally drives branching logic that needs replacing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate &lt;code&gt;/research&lt;/code&gt; callers to &lt;code&gt;/search&lt;/code&gt; with &lt;code&gt;type: "deep-reasoning"&lt;/code&gt;.&lt;/strong&gt; Check &lt;code&gt;outputSchema&lt;/code&gt; is set if you parse the output as JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For ongoing monitoring: schedule a daily script that hits your top Exa endpoints with a representative query, hashes the field set in the response, and alerts when the hash changes. That signal catches every future silent removal — not just Exa's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern, Now Five Months In
&lt;/h2&gt;

&lt;p&gt;This is the twelfth provider I've written up in this series. The shape varies; the failure mode is consistent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;What Goes Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Basil&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Subscription.current_period_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moved to &lt;code&gt;items[].current_period_end&lt;/code&gt;; old reads return &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pull_request.merge_commit_sha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;null&lt;/code&gt; on closed PRs in API ver 2026-03-10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Org security fields&lt;/td&gt;
&lt;td&gt;PATCH returns 200, applies nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Responses &lt;code&gt;input_text&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rejected with &lt;code&gt;Invalid value&lt;/code&gt; error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HubSpot&lt;/td&gt;
&lt;td&gt;Contacts v1 endpoints&lt;/td&gt;
&lt;td&gt;Return 200 with &lt;code&gt;list-memberships&lt;/code&gt; silently dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth0&lt;/td&gt;
&lt;td&gt;TLS handshake&lt;/td&gt;
&lt;td&gt;Weak ciphers start returning &lt;code&gt;handshake_failure&lt;/code&gt; Jun 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twilio&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.de1.twilio.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed; regional domains never actually routed regionally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;td&gt;Checkout &lt;code&gt;metafields&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;undefined&lt;/code&gt; after 2026-04; orders ship without app data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes 1.36&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gitRepo&lt;/code&gt; volumes&lt;/td&gt;
&lt;td&gt;Pass validation, fail at deploy time with FailedMount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude-3-haiku-20240307&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns model-retired error after Apr 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;DALL·E 2/3&lt;/td&gt;
&lt;td&gt;Retired May 12; per-image billing flips to per-token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exa&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;/research&lt;/code&gt; + crawl-date filters + &lt;code&gt;highlightScores&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Endpoint 404, parameters silently ignored, fields &lt;code&gt;null&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twelve providers, twelve different shapes, one shared failure mode: the API still returns 200 (until it doesn't), the SDK still validates, the field your code depends on just isn't there — or the parameter you sent is being thrown away.&lt;/p&gt;

&lt;p&gt;If your AI stack pulls from Exa and you haven't audited the calls yet, today is the day. Tomorrow, "I noticed our research agent is summarizing weirdly old pages" is a much harder bug to track down than "I removed the &lt;code&gt;startCrawlDate&lt;/code&gt; parameter on May 1 because it stopped working."&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for exactly this problem — point it at the API endpoints you depend on (Exa, OpenAI, Stripe, GitHub, anything), and it polls them on a schedule, learns the response shape, and alerts when a field drops, a type flips, or a parameter starts being ignored. Free tier covers up to five endpoints — useful for keeping watch on your top external dependencies without writing the monitor yourself.&lt;/p&gt;

&lt;p&gt;You don't need a tool for this. You do need a habit. The Exa rollout is unusually gentle — the deprecation notice was clear, the changelog was explicit, the migration path was documented. The silent half-broken state still ran for two weeks because nobody had a system for catching "the parameter we send is being thrown away."&lt;/p&gt;

&lt;p&gt;That's the gap. HTTP 200 isn't enough. The shape matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your agent or RAG pipeline tripped on Exa's deprecation today — or any other silent schema change — I'd like to hear about it. The "no error, just degraded output" failures are the ones I'm most interested in. Drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>monitoring</category>
      <category>llm</category>
    </item>
    <item>
      <title>Stripe Basil Quietly Moved current_period_end Off Subscription — And a Lot of Code Broke</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Fri, 01 May 2026 04:02:06 +0000</pubDate>
      <link>https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-kc5</link>
      <guid>https://dev.to/flarecanary/stripe-basil-quietly-moved-currentperiodend-off-subscription-and-a-lot-of-code-broke-kc5</guid>
      <description>&lt;p&gt;On March 31, 2025, Stripe shipped the Basil API version. Among other changes, it removed three fields from the &lt;code&gt;Subscription&lt;/code&gt; object that a lot of production code was reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;current_period_start&lt;/code&gt; — &lt;strong&gt;moved to subscription items&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current_period_end&lt;/code&gt; — &lt;strong&gt;moved to subscription items&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;billing_thresholds&lt;/code&gt; — &lt;strong&gt;removed entirely&lt;/strong&gt; (later reintroduced — more on this)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you upgraded your account's default API version without pinning the SDK, the endpoint still returned &lt;code&gt;200 OK&lt;/code&gt;. The subscription objects still serialized cleanly. The fields your code accessed just came back &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One of those fields is &lt;code&gt;current_period_end&lt;/code&gt;. If your app uses Stripe subscriptions at all, there's a very good chance you read &lt;code&gt;current_period_end&lt;/code&gt; somewhere. Maybe it populates the "next bill date" in your UI. Maybe it drives a cron job that reminds customers before renewal. Maybe it gates feature access for annual plans. Whatever it is, it quietly stopped working.&lt;/p&gt;

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

&lt;p&gt;From the &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-subscription-current-period-start-and-end" rel="noopener noreferrer"&gt;Basil changelog&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;current_period_start&lt;/code&gt; and &lt;code&gt;current_period_end&lt;/code&gt; are removed from subscriptions. Instead, access the subscription item's billing periods directly via &lt;code&gt;items.data[].current_period_start&lt;/code&gt; and &lt;code&gt;items.data[].current_period_end&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rationale is sound. In the old model, every subscription had a single billing cycle. In the new flexible-billing model, each &lt;em&gt;item&lt;/em&gt; in a subscription can have its own cycle — useful for mixing monthly and annual items, or metered and flat items, on one subscription. Moving the fields down to the item level reflects reality.&lt;/p&gt;

&lt;p&gt;From the consumer side, though, the surface looked like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before (2025-02-24.acacia):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"subscription"&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;"sub_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current_period_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1712678400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&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;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After (2025-03-31.basil):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"subscription"&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;"sub_..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"current_period_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"current_period_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1712678400&lt;/span&gt;&lt;span class="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;Still a valid &lt;code&gt;Subscription&lt;/code&gt; object. Still everything serializes. Just no more top-level period fields.&lt;/p&gt;

&lt;p&gt;The breakage affected &lt;strong&gt;every SDK language&lt;/strong&gt; — Node, Python, PHP, Java, Go, .NET. (Ruby, per the changelog, was the only one unaffected by this particular change, because of how that SDK maps the object.)&lt;/p&gt;

&lt;h2&gt;
  
  
  billing_thresholds — Removed, Then Quietly Un-Removed
&lt;/h2&gt;

&lt;p&gt;The other Basil change that bit a lot of teams was &lt;code&gt;billing_thresholds&lt;/code&gt; disappearing. This one has an even weirder story.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;billing_thresholds&lt;/code&gt; was how you told Stripe "automatically invoice this subscription when the customer's usage passes this amount." For metered billing at scale, it was the thing that prevented a single runaway customer from accumulating a six-figure bill you might never collect.&lt;/p&gt;

&lt;p&gt;On 2025-03-31, Stripe removed it from the Subscription API. The &lt;a href="https://docs.stripe.com/changelog/basil/2025-03-31/deprecate-legacy-usage-based-billing" rel="noopener noreferrer"&gt;initial migration advice&lt;/a&gt; pointed teams at "metered billing alerts" as a replacement.&lt;/p&gt;

&lt;p&gt;Developers quickly noticed the replacement wasn't on par. From &lt;a href="https://github.com/stripe/stripe-node/issues/2328" rel="noopener noreferrer"&gt;stripe-node issue #2328&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's not clear why it was removed before the metered version was fully ready."&lt;/p&gt;

&lt;p&gt;"The dashboard still allows setting billing thresholds and even generates the curl command. But when you actually use the same curl request, it produces an error stating the parameter is no longer supported."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Metered billing alerts only fired once per customer lifetime and didn't carry subscription IDs. You could not use them to auto-invoice at a threshold. There was no direct replacement for the thing that had been removed.&lt;/p&gt;

&lt;p&gt;On 2025-05-28, Stripe &lt;a href="https://docs.stripe.com/changelog/basil/2025-05-28/reintroduce-billing-thresholds" rel="noopener noreferrer"&gt;reintroduced billing_thresholds&lt;/a&gt;. The field came back. Anyone who had already rewritten their billing logic to work around its absence had just shipped two migrations to land in the same place.&lt;/p&gt;

&lt;p&gt;This is the &lt;em&gt;other&lt;/em&gt; failure mode of silent schema changes: the churn of reacting to a change, then reacting to its reversal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Most Teams Didn't Catch the Migration
&lt;/h2&gt;

&lt;p&gt;The cleanest version of this upgrade is: pin your SDK to the old Basil-adjacent version, upgrade the SDK deliberately, test against a staging Stripe account, ship. A lot of teams do this. A lot don't.&lt;/p&gt;

&lt;p&gt;Here are the common ways it slipped through:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Library auto-upgrades.&lt;/strong&gt; A Dependabot PR bumps your &lt;code&gt;stripe-node&lt;/code&gt; minor version, tests pass (because fixtures are from before the migration), and you merge it. Your SDK is now Basil-aware, your code still reads &lt;code&gt;subscription.current_period_end&lt;/code&gt;, and the field is &lt;code&gt;undefined&lt;/code&gt; in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-level API version default.&lt;/strong&gt; Stripe lets you upgrade your account's default version in the dashboard. If someone on your team clicks through the upgrade flow without coordinating with engineering, every non-pinned API call starts returning the new shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook events.&lt;/strong&gt; Webhook payloads use the API version in effect at the time the event is created. If your account default shifts, your webhook handlers start receiving new-shape &lt;code&gt;invoice.*&lt;/code&gt; and &lt;code&gt;customer.subscription.*&lt;/code&gt; events — often before your SDK has been upgraded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript didn't save anyone.&lt;/strong&gt; The Stripe Node SDK updates its types with each version. If you pin to an older version, your types still describe &lt;code&gt;current_period_end&lt;/code&gt; as top-level and your code happily accesses it. The runtime object just doesn't have it. TypeScript can't catch that — types are a compile-time shape, not a runtime guarantee.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests that mock Stripe.&lt;/strong&gt; If your tests use recorded fixtures or a mock Stripe library, they validate against yesterday's shape, not today's live API. Your CI stays green while prod returns &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same pattern as every other silent schema change: the thing that's supposed to catch it was designed against the shape that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke In Practice
&lt;/h2&gt;

&lt;p&gt;A few of the concrete failure modes I've seen or heard about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dunning emails stopped.&lt;/strong&gt; The cron that emails customers "your subscription renews on X" read &lt;code&gt;subscription.current_period_end&lt;/code&gt;, got &lt;code&gt;undefined&lt;/code&gt;, and sent "your subscription renews on &lt;strong&gt;Invalid Date&lt;/strong&gt;" — or skipped the send entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer-facing dashboards went blank.&lt;/strong&gt; "Next invoice" widgets showed empty cells for every new subscription created after the cutover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proration math got weird.&lt;/strong&gt; Code that calculated time remaining in the current period used &lt;code&gt;current_period_end - Date.now()&lt;/code&gt;; when the field was undefined, proration defaulted to zero or to a NaN that cascaded into charge amounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting numbers drifted.&lt;/strong&gt; Analytics jobs that grouped subscriptions by current-period end-date started dropping rows because the field was missing from the row entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are showy failures. No 500s, no exceptions in Sentry. Just wrong or missing data on the customer experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration That Actually Works
&lt;/h2&gt;

&lt;p&gt;For the &lt;code&gt;current_period_end&lt;/code&gt; move specifically, the honest migration is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin your SDK&lt;/strong&gt; to the version you've tested. Don't let Dependabot drive your billing API upgrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read periods from items, not the subscription.&lt;/strong&gt; For single-item subscriptions, &lt;code&gt;subscription.items.data[0].current_period_end&lt;/code&gt; is the direct replacement. For multi-item, decide what "period end" means for your use case — the earliest item? The one that matches a specific price? Your code now has to answer that question explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update webhook handlers.&lt;/strong&gt; &lt;code&gt;customer.subscription.updated&lt;/code&gt;, &lt;code&gt;invoice.created&lt;/code&gt;, and related events use the account API version. Test them against the new shape before flipping your account default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage the upgrade behind a feature flag&lt;/strong&gt; if you can. Flip it on in staging, compare prod vs staging subscription reads for a week, then promote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;code&gt;billing_thresholds&lt;/code&gt;, the migration depends on when you started. If you started before 2025-05-28, you might have rewritten on top of metered alerts. Consider whether to revert to &lt;code&gt;billing_thresholds&lt;/code&gt; now that it's back — or stay on whatever you built, since the reintroduction might not be permanent either.&lt;/p&gt;

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

&lt;p&gt;This is the part that generalizes beyond Stripe. Pinning versions works for APIs that version properly. It does not help for the cases where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API version didn't change but the response shape did (GitHub Events API, some OpenAI endpoints)&lt;/li&gt;
&lt;li&gt;You're on an account-default version and someone upstream flipped it&lt;/li&gt;
&lt;li&gt;The SDK was upgraded but the API default was left alone, so your types and runtime disagree&lt;/li&gt;
&lt;li&gt;The change is "allowed" within the version contract (e.g., a new optional field that turns out to be required when you want to match the old UX)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general defense is to watch the shape of the responses you depend on. That can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cron-scheduled script that hits your top N endpoints, hashes the field set, and alerts when the hash changes.&lt;/li&gt;
&lt;li&gt;A test that runs nightly against live endpoints (not fixtures) and asserts on the structure of the response.&lt;/li&gt;
&lt;li&gt;A purpose-built schema drift monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've been building &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for the third option. Point it at the API endpoints you depend on — Stripe, Plaid, OpenAI, Salesforce, whatever — and it polls them on a schedule, learns the response structure, and alerts when a field disappears, a type shifts, or a new field appears. Severity-classified: removed fields are alerts, new optional fields are informational, nullability flips fall somewhere in between.&lt;/p&gt;

&lt;p&gt;You do not strictly need a tool for this. You need &lt;em&gt;a habit&lt;/em&gt;. The Basil migration is a specific case of a very general problem: the APIs you depend on are changing in ways your type system and your CI can't see. The only reliable signal is watching the live response shape and comparing it to yesterday's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Thing That Actually Matters
&lt;/h2&gt;

&lt;p&gt;Stripe did a fine job of the Basil release from the provider side. The changelog was clear. The migration docs existed. The new model is better designed than the old one. The &lt;code&gt;billing_thresholds&lt;/code&gt; reintroduction, awkward as it was, was the right call once the replacement turned out to be insufficient.&lt;/p&gt;

&lt;p&gt;None of that changed the fact that some number of teams woke up one Monday with blank "next invoice" dates in their dashboards because nobody owned "does the shape of the Subscription object match what our code expects?" as a question worth asking before every API version bump.&lt;/p&gt;

&lt;p&gt;That's the monitoring gap. HTTP status codes tell you an endpoint is up. Response latencies tell you it's fast. Neither of those tells you that &lt;code&gt;current_period_end&lt;/code&gt; moved three levels deeper into the response tree.&lt;/p&gt;

&lt;p&gt;If you're on Stripe and haven't explicitly upgraded to Basil yet, this is your warning. If you already upgraded and everything's green, run a grep for &lt;code&gt;current_period_end&lt;/code&gt; across your codebase and see what comes back. It is a very common field, and the number of surprising places it shows up tends to be higher than people expect.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've been bit by the Basil migration — or by any silent schema change on another API — I'd like to hear about it. The "undefined field, no error" failures are the ones I'm most interested in. Drop a comment or reach out.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>api</category>
      <category>billing</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>OpenAI Responses API Started Rejecting input_text With No Warning — Here's the Fix and Why It Keeps Happening</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Thu, 30 Apr 2026 04:01:42 +0000</pubDate>
      <link>https://dev.to/flarecanary/openai-responses-api-started-rejecting-inputtext-with-no-warning-heres-the-fix-and-why-it-keeps-2l49</link>
      <guid>https://dev.to/flarecanary/openai-responses-api-started-rejecting-inputtext-with-no-warning-heres-the-fix-and-why-it-keeps-2l49</guid>
      <description>&lt;p&gt;If you're here because you just got this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invalid value: 'input_text'. Supported values are: 'output_text' and 'refusal'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…from the OpenAI Responses API on a request that worked last week, you're not debugging your code. You're debugging OpenAI's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;On or around October 21, 2025, the OpenAI Responses API started rejecting &lt;code&gt;input_text&lt;/code&gt; content items when they appeared inside a message with &lt;code&gt;role: "assistant"&lt;/code&gt;. The documented format — the one every SDK and every tutorial was using — suddenly returned a 400.&lt;/p&gt;

&lt;p&gt;Before:&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;"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;"assistant"&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;"input_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;"Hi, how can I help?"&lt;/span&gt;&lt;span class="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;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 400 Bad Request
Invalid value: 'input_text'. Supported values are: 'output_text' and 'refusal'.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same endpoint. Same SDK version. Same request body. Just stopped working.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://community.openai.com/t/input-text-field-removed-from-response-api/1363365" rel="noopener noreferrer"&gt;community thread&lt;/a&gt; that went up the day it broke captures the vibe:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I'm wondering why there is no backward compatibility and no even deprecation messages for that. It's really bad user experience."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A moderator responded with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Thanks for flagging this! I asked staff for clarification because apparently everything is gone besides input audio."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Apparently everything is gone" is not how you want to find out your production AI pipeline has been migrated under you.&lt;/p&gt;

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

&lt;p&gt;For assistant-role messages, the type name changed. &lt;code&gt;input_text&lt;/code&gt; is now only valid inside &lt;em&gt;user&lt;/em&gt;-role messages. For assistant messages, you need &lt;code&gt;output_text&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; {
   "role": "assistant",
   "content": [
&lt;span class="gd"&gt;-    { "type": "input_text", "text": "Hi, how can I help?" }
&lt;/span&gt;&lt;span class="gi"&gt;+    { "type": "output_text", "text": "Hi, how can I help?" }
&lt;/span&gt;   ]
 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the whole fix. The semantics are identical; the type tag just has to match the role now.&lt;/p&gt;

&lt;p&gt;If you're building the assistant message from scratch (e.g., seeding a conversation), you use &lt;code&gt;output_text&lt;/code&gt;. If you're echoing back a prior turn, you were probably already using &lt;code&gt;output_text&lt;/code&gt; (because that's what the API returned). The breakage is specifically on the "I manually built an assistant turn" path.&lt;/p&gt;

&lt;p&gt;A few things worth noting before you patch and move on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The error message is honest but only if you read it closely.&lt;/strong&gt; It lists the valid values for the role you're sending — but it doesn't say "try &lt;code&gt;output_text&lt;/code&gt; for assistant messages." You have to infer that from the full schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Realtime API had a nearly identical incident earlier.&lt;/strong&gt; &lt;a href="https://community.openai.com/t/confusing-realtime-api-invalid-value-input-text-value-must-be-text/1068134" rel="noopener noreferrer"&gt;This older thread&lt;/a&gt; shows developers hit with &lt;code&gt;Invalid value: 'input_text'. Value must be 'text'.&lt;/code&gt; in the Realtime API. Same family of problem: the type tag you need depends on which API surface and which role you're using, and the docs don't cross-reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's a related bug report&lt;/strong&gt; open on the WordPress PHP AI client where text-only messages hit the same 400 because the client was constructing &lt;code&gt;input_text&lt;/code&gt; blocks by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why nobody's tests caught it
&lt;/h2&gt;

&lt;p&gt;Every time a silent API change hits, the retrospective hits the same beats:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unit tests don't catch it&lt;/strong&gt; because unit tests mock the API response. The mock was built from the docs. The docs still showed &lt;code&gt;input_text&lt;/code&gt; as valid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration tests don't catch it&lt;/strong&gt; unless they're pointed at the live OpenAI endpoint &lt;em&gt;and&lt;/em&gt; run often enough to catch the change before a customer does. Most teams run integration tests on PRs, not on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type systems don't catch it&lt;/strong&gt; because the official Python and TypeScript SDKs type the &lt;code&gt;content[]&lt;/code&gt; as a union that includes &lt;code&gt;input_text&lt;/code&gt;. The runtime rejects what the static types allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring doesn't catch it&lt;/strong&gt; because a 400 error on a single request looks like a bad input from your own code. You check your code, can't find the bug, and file it under "weird" — until enough customers report it that you realize it's everyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The SDK version didn't change&lt;/strong&gt; because this wasn't a client-side change. Your &lt;code&gt;openai&lt;/code&gt; package is the same version it was yesterday. The server just started enforcing a stricter schema on the exact same request.&lt;/p&gt;

&lt;p&gt;That last one is the killer. Pinning the SDK is the first move most teams reach for when an API breaks, and in this case it does absolutely nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, not just the incident
&lt;/h2&gt;

&lt;p&gt;This is the fourth incident I've written about this month where a top-tier API changed shape without a version bump or a deprecation warning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub PushEvent&lt;/strong&gt; stripped &lt;code&gt;payload.commits&lt;/code&gt; from the Events API in October 2025. No version bump — the Events API isn't versioned. Abuse-detection pipelines ran on empty data for weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe's Basil release&lt;/strong&gt; (2025-03-31) removed &lt;code&gt;current_period_end&lt;/code&gt; and &lt;code&gt;billing_thresholds&lt;/code&gt; from the subscription object. Teams that let their account default version float got silently migrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify's 2025-01 Admin API&lt;/strong&gt; changed &lt;code&gt;fulfillmentHold&lt;/code&gt; from a string to an object and removed &lt;code&gt;PrivateMetafield&lt;/code&gt; entirely. Apps still on 2024-10 started returning nulls where structured data used to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI's Responses API&lt;/strong&gt; — this one. No announcement, no version header to pin, just a stricter server-side validator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common thread: &lt;strong&gt;the API provider has a legitimate reason for the change&lt;/strong&gt; — abuse mitigation, internal consistency, safer defaults — &lt;strong&gt;and the consumer's tests assume a frozen structure.&lt;/strong&gt; The gap between those two realities is where production breaks.&lt;/p&gt;

&lt;p&gt;You can't stop providers from making changes. Most of the changes are even good. What you can do is refuse to find out about them from your own users.&lt;/p&gt;

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

&lt;p&gt;Three defenses, in order of how much they help:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pin the API version where the API supports it.&lt;/strong&gt; Stripe, Shopify, OpenAI's Chat Completions — all of these let you pin. OpenAI's Responses API does not currently expose a version header, which is why this incident hit even teams with otherwise disciplined version management. Pin where you can, and know which of your dependencies don't give you that lever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Assert on response shape in integration tests — and schedule them.&lt;/strong&gt; Not "does our assistant respond?" but "does &lt;code&gt;content[0].type&lt;/code&gt; equal the value we rely on?" And run these tests on a cron against the live endpoint, not just on PRs. Daily is fine. Hourly for anything customer-facing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Monitor the response shape in production.&lt;/strong&gt; Poll the endpoints you depend on (or sample live traffic), record the shape over time, and diff against a learned baseline. When a field changes type, disappears, or a new enum value starts returning 400s, you get an alert — usually hours before customers do.&lt;/p&gt;

&lt;p&gt;The third one is what I've been building at &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt;. Point it at your critical OpenAI, Stripe, GitHub, Shopify endpoints — the ones whose schema changes would ruin a Monday morning — and it polls on a schedule, learns the expected structure, and flags drift. Removed fields. Changed types. Nullability shifts. New fields that might be the start of a migration. Severity-classified so noise stays low.&lt;/p&gt;

&lt;p&gt;You don't need a dedicated tool for this. You can cron a script that hits your top handful of endpoints, hashes the field set, and diffs. The point is that &lt;em&gt;some&lt;/em&gt; layer has to be watching response shape, because nothing else in your stack is.&lt;/p&gt;

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

&lt;p&gt;Every one of these incidents ended the same way: developers on a community forum, pasting the error, asking if anyone else had hit it. That's the monitoring layer right now. It's community forums and other people's stack traces.&lt;/p&gt;

&lt;p&gt;Most teams know their dependency graph at the package level. They can tell you which OpenAI model they call, what GitHub endpoints they depend on, which Stripe SDK is in &lt;code&gt;package.json&lt;/code&gt;. Almost none of them can tell you whether any of those endpoints has returned a different response shape in the last week.&lt;/p&gt;

&lt;p&gt;HTTP 200s tell you the endpoint is up. Latency tells you it's fast. Neither of those tells you the contract is still what you thought.&lt;/p&gt;

&lt;p&gt;If you'd been diffing Responses API calls against a baseline on October 20, you'd have had an alert on October 21 — before any of your customers opened a ticket. The capability exists. The habit doesn't.&lt;/p&gt;

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




&lt;p&gt;&lt;em&gt;If you've been hit by an API schema change that slipped through your tests — especially the "same SDK version, same request, suddenly a 400" variety — drop a reply. I've been collecting these and the pattern is remarkably consistent.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openai</category>
      <category>api</category>
      <category>ai</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Kubernetes 1.36 Removed gitRepo Volumes — Your Helm Charts Pass Validation, Your Pods Don't Schedule</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Wed, 29 Apr 2026 04:02:08 +0000</pubDate>
      <link>https://dev.to/flarecanary/kubernetes-136-removed-gitrepo-volumes-your-helm-charts-pass-validation-your-pods-dont-schedule-4g51</link>
      <guid>https://dev.to/flarecanary/kubernetes-136-removed-gitrepo-volumes-your-helm-charts-pass-validation-your-pods-dont-schedule-4g51</guid>
      <description>&lt;p&gt;Kubernetes v1.36 shipped on April 22, 2026, and one removal is worth a runbook entry on its own: the in-tree &lt;code&gt;gitRepo&lt;/code&gt; volume driver is gone.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gitRepo&lt;/code&gt; was the volume type that let a pod clone a git repository at startup straight into a mounted volume. It had been deprecated since v1.11 (mid-2018), flagged for security issues (CVE-2024-10220, the symlink escape that let containers traverse out of the cloned tree), and finally retired in 1.36. There is no replacement field. There is no compatibility shim. The schema validator on a 1.36 API server still accepts the field — it has to, because old manifests in etcd reference it — but the kubelet refuses to mount it.&lt;/p&gt;

&lt;p&gt;The result is a deploy-time failure that sails through every pre-deploy gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI/CD pipeline that lies to you
&lt;/h2&gt;

&lt;p&gt;A Helm chart that includes a gitRepo volume looks fine to every tool in the chain:&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;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-config&lt;/span&gt;
    &lt;span class="na"&gt;gitRepo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://github.com/acme/config-repo.git"&lt;/span&gt;
      &lt;span class="na"&gt;revision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;helm lint&lt;/code&gt; passes. &lt;code&gt;helm template&lt;/code&gt; renders. &lt;code&gt;kubectl apply --dry-run=server&lt;/code&gt; against a 1.36 cluster returns &lt;code&gt;pod/foo created (dry run)&lt;/code&gt; — because the API server still validates the schema. The CI pipeline goes green.&lt;/p&gt;

&lt;p&gt;At actual apply time on a 1.36 node, the kubelet refuses to mount the volume and the pod sits in &lt;code&gt;ContainerCreating&lt;/code&gt; forever, with events that say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning  FailedMount  kubelet  MountVolume.SetUp failed for volume "app-config":
gitRepo volume plugin is no longer supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the Helm chart's metadata indicates that this volume type is gone. Nothing in the chart's chart.yaml signals incompatibility with k8s 1.36. The chart's &lt;code&gt;kubeVersion&lt;/code&gt; field is advisory, and most public charts don't update it for individual volume removals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this hides in real codebases
&lt;/h2&gt;

&lt;p&gt;Three places where gitRepo volumes survive in 2026 codebases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Internal "config-as-code" sidecars.&lt;/strong&gt; Pre-2020 pattern: a sidecar mounts a gitRepo volume to pull the latest config repo on pod restart. Replaced in most teams by ConfigMaps or Vault, but legacy clusters and forgotten staging environments often still run it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm charts pinned to an old version.&lt;/strong&gt; Charts on Artifact Hub from 2018-2020 era that haven't been re-published. &lt;code&gt;helm install&lt;/code&gt; against a pinned version still pulls the old manifest. The chart's stated kubeVersion range often hasn't been narrowed to exclude 1.36.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom operators that generate Pod specs.&lt;/strong&gt; Operators written by platform teams that emit gitRepo volumes for config-loading. The operator itself doesn't fail upgrade, but the pods it generates do — and the operator's own readiness probe is usually unaware that its child workloads aren't running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third category is the most painful. The operator reports "healthy." The CRD instances are &lt;code&gt;Reconciled&lt;/code&gt;. Only when you look at the pods directly do you see they've been stuck &lt;code&gt;ContainerCreating&lt;/code&gt; for hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that's not a search-and-replace
&lt;/h2&gt;

&lt;p&gt;Kubernetes' own deprecation notice points at three replacements: an init container that runs &lt;code&gt;git clone&lt;/code&gt;, the &lt;code&gt;git-sync&lt;/code&gt; sidecar project, or an external operator like flux/argo for actual GitOps. Each one has different semantics from gitRepo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Volume mode&lt;/th&gt;
&lt;th&gt;Auth&lt;/th&gt;
&lt;th&gt;Refresh&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gitRepo&lt;/code&gt; (removed)&lt;/td&gt;
&lt;td&gt;Cloned once at pod create&lt;/td&gt;
&lt;td&gt;None (public repos only)&lt;/td&gt;
&lt;td&gt;Pod restart only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Init container&lt;/td&gt;
&lt;td&gt;Cloned once at pod create&lt;/td&gt;
&lt;td&gt;Pass via secret-mounted volume&lt;/td&gt;
&lt;td&gt;Pod restart only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git-sync&lt;/code&gt; sidecar&lt;/td&gt;
&lt;td&gt;Continuously synced&lt;/td&gt;
&lt;td&gt;Service account, SSH key, or PAT&lt;/td&gt;
&lt;td&gt;Configurable interval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Argo/Flux operator&lt;/td&gt;
&lt;td&gt;Cluster-wide reconcile&lt;/td&gt;
&lt;td&gt;OIDC / GitHub App&lt;/td&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The init-container approach is closest to drop-in, but it requires you to add an &lt;code&gt;emptyDir&lt;/code&gt; volume the init container writes into and the main container reads from, plus a secret mount if your repo isn't public (gitRepo never supported auth, so anyone migrating from gitRepo is by definition working with public repos — but that means they're now exposed to the same supply-chain issues that made gitRepo unsafe in the first place).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git-sync&lt;/code&gt; is the closest in spirit. It's an actively maintained project, lives as a sidecar, and does periodic refresh. But it changes pod resource accounting, and on small clusters the extra container can push pods over their memory limits.&lt;/p&gt;

&lt;p&gt;The Helm-chart fix isn't a one-line swap. Expect it to touch the pod spec, add an emptyDir volume, add an init container or sidecar, configure auth if the repo isn't public, and update probes if your liveness check assumed instant volume availability at container start.&lt;/p&gt;

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

&lt;p&gt;Three actions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep your manifests, charts, and operator code for &lt;code&gt;gitRepo:&lt;/code&gt;.&lt;/strong&gt; If you're still on 1.35 or earlier, you have until your next upgrade to fix it. If you're on 1.36 already and apply hasn't broken yet, you're flying on workloads that haven't been restarted since the upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit your operator-generated Pod specs.&lt;/strong&gt; Run &lt;code&gt;kubectl get pods -A -o json | jq '.items[].spec.volumes[]? | select(.gitRepo)'&lt;/code&gt; against your clusters. Anything that comes back is a future &lt;code&gt;FailedMount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin chart versions explicitly with kubeVersion guards.&lt;/strong&gt; When you migrate, narrow the chart's &lt;code&gt;kubeVersion&lt;/code&gt; to &lt;code&gt;&amp;lt; 1.36&lt;/code&gt; for the legacy version and bump the major for the migrated version. This stops &lt;code&gt;helm upgrade --install&lt;/code&gt; from silently rolling forward to a broken combination.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worth bonus: &lt;code&gt;Ingress NGINX&lt;/code&gt; was retired entirely on March 24, 2026 — no more releases, bugfixes, or security updates. If your cluster's Ingress controller is &lt;code&gt;ingress-nginx&lt;/code&gt; (the Kubernetes-project one, not NGINX Inc.'s &lt;code&gt;nginx-ingress&lt;/code&gt;), the migration to InGate or NGINX Gateway Fabric is on the same runbook page.&lt;/p&gt;

&lt;p&gt;The "everything passes, then breaks at deploy" pattern is what makes K8s upgrades load-bearing for an entire engineering org. The 1.36 gitRepo removal is the cleanest example we've seen this year of CI/CD validation that proves nothing about runtime behavior.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for the API-side version of this same pattern: schema accepts the request, response shape passes validation, but the field semantics changed. Same problem, different layer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your org runs Kubernetes 1.35 or earlier and any team has a gitRepo volume in their chart, mark the next 1.36 upgrade window as a known-risk deploy. Operator-generated pods are the easiest to miss.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cicd</category>
      <category>helm</category>
    </item>
    <item>
      <title>GitHub Just Retired Seven Org Security Fields — Your 'New Repo Hardening' Script Is Now A No-Op</title>
      <dc:creator>FlareCanary</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:01:30 +0000</pubDate>
      <link>https://dev.to/flarecanary/github-just-retired-seven-org-security-fields-your-new-repo-hardening-script-is-now-a-no-op-3id7</link>
      <guid>https://dev.to/flarecanary/github-just-retired-seven-org-security-fields-your-new-repo-hardening-script-is-now-a-no-op-3id7</guid>
      <description>&lt;p&gt;GitHub announced on April 21 that seven security-defaults fields on the org REST endpoint are now retired:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;advanced_security_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dependabot_alerts_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dependabot_security_updates_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dependency_graph_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;secret_scanning_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;secret_scanning_push_protection_enabled_for_new_repositories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;secret_scanning_push_protection_custom_link_enabled&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These appeared on &lt;code&gt;GET /orgs/{org}&lt;/code&gt; and were writable on &lt;code&gt;PATCH /orgs/{org}&lt;/code&gt;. The retirement isn't a 410 or a schema-validation error. The fields just stop appearing on reads. The PATCH still returns 200 OK whether you send them or not — it just no longer applies them.&lt;/p&gt;

&lt;p&gt;Replacement: &lt;a href="https://docs.github.com/en/rest/code-security/configurations" rel="noopener noreferrer"&gt;Code Security Configurations API&lt;/a&gt; (&lt;code&gt;/orgs/{org}/code-security/configurations&lt;/code&gt;). Different endpoint, different shape, different concept — configurations are objects you attach to repositories, not booleans on the org.&lt;/p&gt;

&lt;p&gt;This is the deepest silent-fail vector we've seen all month. It's not a billing column or a string field. It's the one-line setup script every platform-engineering team wrote to harden new repos by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script that quietly stopped working
&lt;/h2&gt;

&lt;p&gt;The classic version:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;octokit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;org&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;acme-platform&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;advanced_security_enabled_for_new_repositories&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="na"&gt;dependabot_alerts_enabled_for_new_repositories&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="na"&gt;secret_scanning_enabled_for_new_repositories&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="na"&gt;secret_scanning_push_protection_enabled_for_new_repositories&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pre-2026-04-21: that PATCH applied org-wide defaults. Every new repo created in &lt;code&gt;acme-platform&lt;/code&gt; after this call had Dependabot, advanced security, and secret scanning on at creation.&lt;/p&gt;

&lt;p&gt;Post-retirement: that PATCH returns 200. Octokit doesn't throw. The audit log records the call. The fields are simply ignored.&lt;/p&gt;

&lt;p&gt;A new repo created tomorrow has none of those defaults applied. The compliance script ran. The dashboard says "secret scanning enabled org-wide." A &lt;code&gt;git push&lt;/code&gt; with an AWS access key in it goes through unchallenged.&lt;/p&gt;

&lt;p&gt;The terraform-github-provider equivalent (&lt;a href="https://registry.terraform.io/providers/integrations/github/latest/docs/resources/organization_settings" rel="noopener noreferrer"&gt;&lt;code&gt;github_organization_settings&lt;/code&gt;&lt;/a&gt;) wraps the same fields and has the same behavior. &lt;code&gt;terraform apply&lt;/code&gt; says no changes. The state file thinks it's converged. The org isn't enforcing what the state file claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reads break differently from writes
&lt;/h2&gt;

&lt;p&gt;GitHub's REST API doesn't versioned-deprecate fields the way it does for &lt;code&gt;merge_commit_sha&lt;/code&gt;. There's no &lt;code&gt;X-GitHub-Api-Version: 2022-11-28&lt;/code&gt; you can set to keep the old field alive. The fields are just gone from &lt;code&gt;GET /orgs/{org}&lt;/code&gt; everywhere now.&lt;/p&gt;

&lt;p&gt;Reading code that does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;org&lt;/span&gt; &lt;span class="p"&gt;}&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;octokit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgs&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="nx"&gt;org&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;org&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret_scanning_enabled_for_new_repositories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…now hits &lt;code&gt;undefined&lt;/code&gt;. JavaScript's truthiness rules turn that into the false branch. Compliance dashboards that read these fields to display "✓ Secret scanning on" suddenly read "✗" and a security engineer pages someone, OR — worse — the dashboard caches the last known value from a week ago and keeps showing green for months.&lt;/p&gt;

&lt;p&gt;Writing code is the more dangerous shape. The PATCH endpoint accepts arbitrary unknown keys without erroring. There's no "did this field actually apply" affordance in the response. Your script can include 47 retired-or-renamed properties and the API still returns 200 with the org object that has none of them on it. Comparing the response back is the only check, and almost nobody writes that compare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replacement is not a one-line swap
&lt;/h2&gt;

&lt;p&gt;The Code Security Configurations API is conceptually different. Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;PATCH /orgs/{org}  { secret_scanning_enabled_for_new_repositories: true }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…you now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /orgs/{org}/code-security/configurations&lt;/code&gt; to create a &lt;em&gt;configuration object&lt;/em&gt; (with secret scanning, Dependabot, advanced security, push protection settings as nested objects).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /orgs/{org}/code-security/configurations/{config_id}/defaults&lt;/code&gt; to attach it as the default for new repos. The body specifies which repo types it applies to (&lt;code&gt;new_repos&lt;/code&gt;, &lt;code&gt;private_repos&lt;/code&gt;, &lt;code&gt;public_repos&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Optionally, &lt;code&gt;POST /orgs/{org}/code-security/configurations/{config_id}/attach&lt;/code&gt; to retroactively apply it to existing repos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shape of the configuration object isn't a 1:1 mapping. &lt;code&gt;advanced_security&lt;/code&gt; becomes part of a nested struct. &lt;code&gt;secret_scanning_push_protection_custom_link&lt;/code&gt; becomes a sub-field of secret scanning. Some legacy combinations aren't expressible. Some new combinations are.&lt;/p&gt;

&lt;p&gt;Migration is a real engineering task, not a search-and-replace. And the deprecation notice doesn't have a hard cutoff date — the fields are listed as "Retired" but reads have already broken, so there's no grace window left.&lt;/p&gt;

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

&lt;p&gt;The pattern by now is familiar. Three things have to be true to catch this in CI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your fixture for &lt;code&gt;GET /orgs/{org}&lt;/code&gt; was regenerated against the live API after April 21 (it wasn't).&lt;/li&gt;
&lt;li&gt;Your test asserts presence (&lt;code&gt;expect(org).toHaveProperty('secret_scanning_enabled_for_new_repositories')&lt;/code&gt;) rather than truthiness on a possibly-undefined field (most don't).&lt;/li&gt;
&lt;li&gt;Your terraform plan/apply tests run against a live GitHub org and diff the result, not against a mocked provider (almost nobody does this).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most platform teams test their org-hardening script by spinning up a sandbox org once, validating the fields land, and never re-validating. The sandbox org's settings still look right because they were set before the retirement. The script that "still works" hasn't actually applied anything for any new repo created after April 21.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;th&gt;Where tests missed it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;GitHub PushEvent&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;commits&lt;/code&gt; field silently dropped&lt;/td&gt;
&lt;td&gt;Tests didn't assert field presence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Stripe Basil&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;current_period_end&lt;/code&gt; moved to &lt;code&gt;items&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Tests used Checkout fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Shopify 2025-01&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fulfillmentHold&lt;/code&gt; type change&lt;/td&gt;
&lt;td&gt;Tests mocked the response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;OpenAI Responses&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;input_text&lt;/code&gt; removed for assistants&lt;/td&gt;
&lt;td&gt;Tests covered request role=user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Twilio regional&lt;/td&gt;
&lt;td&gt;Regional domains stop resolving&lt;/td&gt;
&lt;td&gt;Tests don't hit prod DNS paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;HubSpot Contacts v1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list-memberships&lt;/code&gt; returns empty&lt;/td&gt;
&lt;td&gt;Tests asserted against sandbox fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;GitHub merge_commit_sha&lt;/td&gt;
&lt;td&gt;Field removed from PR responses&lt;/td&gt;
&lt;td&gt;Tests used pre-rollout fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;GitHub org security fields&lt;/td&gt;
&lt;td&gt;7 fields retired from /orgs/{org}&lt;/td&gt;
&lt;td&gt;Org-hardening script tested once, never re-run&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight in a row. The breaking change is always in a field a test isn't asserting against, in a layer (transitive SDK upgrade, version-pinned header, configuration drift) the test isn't exercising, or in a script that runs once during onboarding and is never re-validated.&lt;/p&gt;

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

&lt;p&gt;Three actions, in priority order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grep your platform-eng repos for the seven field names.&lt;/strong&gt; Anywhere they appear in a PATCH body or a read assertion is a candidate failure. Org-hardening Octokit calls, Terraform configs, compliance dashboards, audit-log queries — all suspect.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit any new repository created in your org after April 21, 2026.&lt;/strong&gt; Check whether secret scanning, Dependabot alerts, and advanced security are actually enabled on each one. If your org-default script broke silently, every new repo since then has weaker security than the dashboard claims.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Migrate to Code Security Configurations.&lt;/strong&gt; Create the configuration, attach it as a default for new repos, and — separately — attach it retroactively to repos created during the silent-fail window. The retroactive attach is the cleanup step most migrations forget.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The PATCH that returns 200 OK and does nothing is the worst kind of API regression. There's no error to alert on. The audit log shows you ran the script. Compliance reports green. And every new repo in your org for the past week has its security defaults in whatever state GitHub decided to leave them in — which, based on testing, is "not what your script set."&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://flarecanary.com" rel="noopener noreferrer"&gt;FlareCanary&lt;/a&gt; for exactly this layer. Snapshot the response shape on a schedule, diff it, alert when fields disappear or accept-but-ignore semantics shift. The GitHub org security retirement is the textbook case: no error, no version header, no migration warning, just seven fields that used to enforce things and now silently don't.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If your platform team's onboarding script touched any of these seven org-level fields and hasn't been audited since April 21, your org is shipping new repos with whatever default security GitHub decided on — not what your runbook says. Worth a Tuesday afternoon.&lt;/em&gt;&lt;/p&gt;

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